tatara_process/allocation.rs
1//! `EphemeralAllocation` CRD — a typed request for a pool member.
2//!
3//! Pairs with `EphemeralPool`: an Allocation is the request side;
4//! the pool reconciler answers it by matching one of its free
5//! Process members and stamping the requestor's identity on the
6//! Allocation's status.
7//!
8//! Topology:
9//! - The requestor (GitHub PR webhook, CI runner, operator running
10//! `feira allocation request …`) creates an `EphemeralAllocation`.
11//! - The pool reconciler watches Allocations; matches `spec.poolRef`
12//! (or routes via PoolSelector if `poolRef` is omitted) to a pool;
13//! picks one Free member; transitions the member to Allocated and
14//! the Allocation to Bound.
15//! - When the requestor is done, it deletes the Allocation. The pool
16//! reconciler honors the pool's `returnPolicy` (Reset / Replace /
17//! Keep).
18
19use chrono::{DateTime, Utc};
20use kube::CustomResource;
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23
24use crate::pool::AllocationRef;
25
26/// `EphemeralAllocation` CRD spec — a typed request for a pool member.
27///
28/// ```yaml
29/// apiVersion: tatara.pleme.io/v1alpha1
30/// kind: EphemeralAllocation
31/// metadata:
32/// name: pr-123-demo-app
33/// namespace: ephemeral-pools
34/// spec:
35/// poolRef:
36/// name: attest-pool
37/// namespace: ephemeral-pools
38/// requestor:
39/// kind: github-pr
40/// repo: "pleme-io/demo-app"
41/// branch: "fix-something"
42/// prNumber: 123
43/// prLabels: ["needs-ephemeral"]
44/// ttl: "1h"
45/// ```
46#[derive(CustomResource, Clone, Debug, Deserialize, Serialize, JsonSchema)]
47#[kube(
48 group = "tatara.pleme.io",
49 version = "v1alpha1",
50 kind = "EphemeralAllocation",
51 plural = "ephemeralallocations",
52 shortname = "ealloc",
53 namespaced,
54 status = "AllocationStatus",
55 printcolumn = r#"{"name":"Pool","type":"string","jsonPath":".spec.poolRef.name"}"#,
56 printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
57 printcolumn = r#"{"name":"Process","type":"string","jsonPath":".status.assignedProcess.name"}"#,
58 printcolumn = r#"{"name":"Requestor","type":"string","jsonPath":".spec.requestor.kind"}"#,
59 printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
60)]
61#[serde(rename_all = "camelCase")]
62pub struct AllocationSpec {
63 /// Direct pool reference. When set, skip selector-based routing.
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub pool_ref: Option<AllocationRef>,
66
67 /// Who is asking for the env.
68 pub requestor: Requestor,
69
70 /// How long the requestor needs the env (`humantime`). The pool
71 /// reconciler clamps this to `pool.spec.maxAllocationTtl`.
72 /// When unset, falls back to the pool's `template.ttl`.
73 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub ttl: Option<String>,
75
76 /// Operator-supplied notes — surfaced in `feira allocation list`
77 /// for audit / debugging context.
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub note: Option<String>,
80}
81
82impl AllocationSpec {
83 /// The canonical minimal [`AllocationSpec`] composer — binds only
84 /// the single caller-varying [`Self::requestor`] slot and leaves
85 /// the three-slot default tail (`pool_ref = None`, `ttl = None`,
86 /// `note = None`) at ONE substrate owner. The lift of the 5-line
87 /// `AllocationSpec { pool_ref: None, requestor: <r>, ttl: None,
88 /// note: None }` incantation past the ★★ PRIME-DIRECTIVE ≥ 2
89 /// duplication threshold — pre-lift the SAME requestor-only
90 /// fixture shape recurred at NINE workspace-wide fixture sites
91 /// (five inside [`crate::allocation`]'s own test module — the
92 /// `alloc_with_phase` / `alloc_without_status` observers on the
93 /// phase axis, the `alloc_with_bound_pool` observer on the
94 /// routing axis, the `alloc_with_expires_at` observer on the TTL
95 /// axis, and the `allocation_spec_omits_optional_fields` wire-
96 /// shape pin; three inside [`crate::lib`]'s pin fixtures — the
97 /// `alloc_fixture` + two `empty_alloc_spec` helpers on the
98 /// coordinate / annotation axes; one inside `tatara-pool-
99 /// reconciler::allocation_decide` — the `alloc` fixture seeding
100 /// the pool convergence-decision test battery). All nine sites
101 /// walked the SAME three-slot default tail — differing only in
102 /// the caller-varying [`Requestor`] value.
103 ///
104 /// The three-slot default tail is the SAFE minimal shape: `pool_ref
105 /// = None` triggers selector-based routing (rather than pinning a
106 /// specific pool), `ttl = None` falls back to the pool template's
107 /// TTL, `note = None` leaves the audit slot empty. Every
108 /// [`crate::pool::PoolSelector`] filter sees "no direct pool
109 /// binding" and matches every candidate pool for the requestor's
110 /// kind; post-lift a callsite that legitimately overrides a slot
111 /// lands the override at its own site via struct-update on top
112 /// of `requestor_only`.
113 ///
114 /// A future addition to [`AllocationSpec`] — a new optional slot
115 /// (e.g. a `priority` for admission-control kinds, a `budget`
116 /// for cost-accounting, a `labels` set for per-allocation
117 /// tagging) — lands at ONE primitive body and every fixture /
118 /// default-shape callsite inherits the upgrade mechanically.
119 /// Pre-lift a new field would have broken all NINE callsites
120 /// (each holds an exhaustive struct literal); post-lift only
121 /// sites that legitimately override the new slot need to name it.
122 ///
123 /// Sibling substrate primitives on the same "bind-the-required-
124 /// slot-only" axis: [`Requestor::kind_only`] (Requestor kind-
125 /// only composer; the six-slot Requestor counterpart, one of the
126 /// values this composer stamps into its own `requestor` slot),
127 /// [`crate::intent::AplicacaoIntent::chart_only`] (Aplicacao
128 /// chart-pointer-only composer; the 7-slot Aplicacao counterpart),
129 /// [`crate::pool::PoolSpec::with_template`] (Pool template-only
130 /// composer; the 11-slot Pool counterpart), and
131 /// [`crate::spec::ProcessSpec::gate_compute_defaults`] (Process
132 /// zero-arg-fixture composer; the 12-slot Process counterpart).
133 ///
134 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
135 /// the 3-slot default tail recurred at NINE hand-authored sites
136 /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning
137 /// two workspace crates, and is lifted onto the ONE workspace-
138 /// wide substrate owner here). THEORY.md §II.1 invariant 5
139 /// (composition preserves proofs — the pin block below binds the
140 /// primitive at fail-before-pass-after granularity so a
141 /// regression that drifted any of the three default-tail slots
142 /// surfaces at THESE pins rather than as silent fixture skew
143 /// across the nine downstream consumers).
144 #[must_use]
145 pub fn requestor_only(requestor: Requestor) -> Self {
146 Self {
147 pool_ref: None,
148 requestor,
149 ttl: None,
150 note: None,
151 }
152 }
153}
154
155/// Identity + routing context for a request.
156#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
157#[serde(rename_all = "camelCase")]
158pub struct Requestor {
159 /// Discriminator: `"github-pr"`, `"manual"`, `"ci-run"`,
160 /// `"scheduled"`, … The wire shape is open by design — operators
161 /// may register their own kinds and the [`crate::pool::PoolSelector`]
162 /// matches on raw string equality. The substrate's own emitters
163 /// stamp one of the four canonical kebab-case kinds enumerated by
164 /// [`RequestorKind::ALL`]; [`Requestor::known_kind`] projects the
165 /// open wire field through that closed-set view at ONE site so
166 /// future kind-keyed consumers (pool dashboards, completion lists,
167 /// audit-trail classifiers) sweep the typed variants without
168 /// re-implementing `match self.kind.as_str()` arm-by-arm. Sibling
169 /// shape to [`crate::receipt::ReceiptEnvelope::known_kind`].
170 pub kind: String,
171
172 /// Optional repo identifier (e.g., `"pleme-io/demo-app"`).
173 /// Matched against `PoolSelector.repos`.
174 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub repo: Option<String>,
176
177 /// Optional branch name. Matched against `PoolSelector.branches`.
178 #[serde(default, skip_serializing_if = "Option::is_none")]
179 pub branch: Option<String>,
180
181 /// Optional PR number (for `kind: github-pr`). Surfaces in
182 /// printcolumns + audit.
183 #[serde(default, skip_serializing_if = "Option::is_none")]
184 pub pr_number: Option<u64>,
185
186 /// Optional commit SHA (for `kind: github-pr` or `ci-run`).
187 /// Stamped onto the allocated Process for traceability.
188 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub sha: Option<String>,
190
191 /// PR / commit labels — matched as a subset against
192 /// `PoolSelector.prLabels`.
193 #[serde(default)]
194 pub pr_labels: Vec<String>,
195
196 /// Free-form actor — username, CI runner ID, etc.
197 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub actor: Option<String>,
199}
200
201impl Requestor {
202 /// Decode [`Self::kind`] into the typed [`RequestorKind`] variant
203 /// when the wire string matches one of the four substrate-emitted
204 /// canonical kebab-case kinds; `None` when the kind is an
205 /// operator-registered open string (the schema is open by design —
206 /// every allocation remains a valid allocation, but only typed
207 /// kinds participate in closed-set dispatch). The (open `String`,
208 /// closed-typed view) split lets future kind-keyed consumers
209 /// (pool-selector classifiers, dashboard completion, audit-trail
210 /// classifiers) sweep the typed variants without touching the
211 /// open-by-design wire shape. Lifted as the canonical decode site
212 /// so no consumer re-implements the `match self.kind.as_str()` arm-
213 /// by-arm — the closed-set sweep happens through
214 /// [`RequestorKind::from_str`] at ONE site. Sibling shape to
215 /// [`crate::receipt::ReceiptEnvelope::known_kind`].
216 #[must_use]
217 pub fn known_kind(&self) -> Option<RequestorKind> {
218 self.kind.parse().ok()
219 }
220
221 /// The canonical minimal [`Requestor`] composer — binds only the
222 /// single caller-varying [`Self::kind`] slot and leaves the
223 /// six-slot default tail (`repo = None`, `branch = None`,
224 /// `pr_number = None`, `sha = None`, `pr_labels = vec![]`,
225 /// `actor = None`) at ONE substrate owner. The lift of the 9-line
226 /// `Requestor { kind: <lit>.into(), repo: None, branch: None,
227 /// pr_number: None, sha: None, pr_labels: vec![], actor: None }`
228 /// incantation past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
229 /// threshold — pre-lift the SAME kind-only fixture shape recurred
230 /// at TEN workspace-wide fixture sites (six inside
231 /// [`crate::allocation`]'s own test module — the
232 /// `known_kind_decodes_built_requestors` sweep, the
233 /// `known_kind_returns_none_for_open_kinds` open-kind pin, the
234 /// `alloc_with_phase` / `alloc_without_status` observers on the
235 /// phase axis, the `bound_pool` observer on the routing axis, the
236 /// `expires_at` observer on the TTL axis, and the
237 /// `allocation_spec_omits_optional_fields` wire-shape pin; three
238 /// inside [`crate::lib`]'s pin fixtures (the `alloc_fixture` +
239 /// two `empty_alloc_spec` helpers on the coordinate / annotation
240 /// axes)).
241 ///
242 /// `impl Into<String>` on the argument accepts every pre-lift
243 /// caller shape verbatim without an argument recast:
244 /// * `Requestor::kind_only("manual")` — the operator-authored
245 /// default fixture, matching the pre-lift `kind: "manual".into()`.
246 /// * `Requestor::kind_only("github-pr")` — the GitHub-webhook
247 /// fixture, matching the pre-lift `kind: "github-pr".into()`.
248 /// * `Requestor::kind_only(RequestorKind::GithubPr)` — the
249 /// typed-round-trip callsite, matching the pre-lift `kind: k
250 /// .into()` where `k: RequestorKind` composes through the
251 /// `From<RequestorKind> for String` bridge.
252 /// * `Requestor::kind_only("operator-custom-kind")` — the
253 /// open-kind pin, matching the pre-lift `kind:
254 /// "operator-custom-kind".into()`.
255 ///
256 /// The six-slot default tail is the SAFE minimal shape: every
257 /// [`crate::pool::PoolSelector`] filter sees "no repo constraint,
258 /// no branch constraint, no PR labels" and matches every pool
259 /// (post-lift a callsite that legitimately overrides a slot lands
260 /// the override at its own site via struct-update on top of
261 /// `kind_only`). A future addition to [`Requestor`] — a new
262 /// optional slot (e.g. a `run_id` for CI kinds, an `email` for
263 /// scheduled kinds, a `cluster` scoping override) — lands at ONE
264 /// primitive body and every fixture / default-shape callsite
265 /// inherits the upgrade mechanically. Pre-lift a new field would
266 /// have broken all TEN callsites (each holds an exhaustive struct
267 /// literal); post-lift only sites that legitimately override the
268 /// new slot need to name it.
269 ///
270 /// Sibling substrate primitives on the same "bind-the-required-
271 /// slots-only" axis: [`crate::intent::AplicacaoIntent::chart_only`]
272 /// (Aplicacao chart-pointer-only composer; the 7-slot Aplicacao
273 /// counterpart), [`crate::pool::PoolSpec::with_template`] (Pool
274 /// template-only composer; the 11-slot Pool counterpart), and
275 /// [`crate::spec::ProcessSpec::gate_compute_defaults`] (Process
276 /// zero-arg-fixture composer; the 12-slot Process counterpart).
277 #[must_use]
278 pub fn kind_only(kind: impl Into<String>) -> Self {
279 Self {
280 kind: kind.into(),
281 repo: None,
282 branch: None,
283 pr_number: None,
284 sha: None,
285 pr_labels: vec![],
286 actor: None,
287 }
288 }
289}
290
291/// Closed-set view over the substrate-emitted canonical
292/// [`Requestor::kind`] wire strings — the four kebab-case
293/// discriminators every pleme-io requestor stamps onto an
294/// [`EphemeralAllocation`]: `github-pr` (the [`tatara_github_watcher`-
295/// authored](../../tatara-github-watcher/src/allocation_factory.rs)
296/// PR-driven path), `manual` (operator-authored via `feira allocation
297/// request …`), `ci-run` (non-PR CI driver), and `scheduled` (a
298/// cron-style emitter). The wire field stays `pub kind: String` on
299/// [`Requestor`] so operators can register their own kinds without a
300/// schema bump; this enum is the typed view future kind-keyed
301/// consumers (pool dashboards, LSP completion, audit-trail
302/// classifiers) sweep against.
303///
304/// Pre-lift the four canonical kinds existed only as `&'static str`
305/// literals at four scattered sites — the documentation header on
306/// [`Requestor::kind`], the [`crate::pool::PoolSelector::kinds`]
307/// docstring, the `tatara-github-watcher` allocation factory, and the
308/// per-test `kind: "github-pr".into()` fixtures. A rename of one
309/// canonical kind (e.g. `"github-pr"` → `"github-pull-request"`) had
310/// no compile-time link to the others, so the documentation drifted
311/// independently of the emitter, and the [`PoolSelector::matches`]
312/// kind-filter silently kept matching the old spelling forever. Post-
313/// lift the (canonical-name, typed-variant) pairing binds at ONE site
314/// ([`Self::as_str`]); the `From<RequestorKind> for String` bridge
315/// lets emitters compose `Requestor { kind: RequestorKind::GithubPr.into(), … }`
316/// so the four canonical strings stop appearing as bare `&'static str`
317/// literals at author sites.
318///
319/// Adding a fifth kind (e.g. `Slack` → `"slack"`, `Webhook` →
320/// `"webhook"`) lands at one [`Self::ALL`] entry + one [`Self::as_str`]
321/// arm — exhaustively checked by the compiler (the `[Self; 4]` array
322/// literal forces the arity) AND by the per-variant truth-table tests
323/// below.
324///
325/// Sibling closed-set `ALL`-keyed lifts across the crate:
326/// [`crate::receipt::ReceiptKind::ALL`] (the four substrate-emitted
327/// receipt kinds — direct shape peer, same open-wire + closed-view
328/// split), [`AllocationPhase::ALL`], [`crate::phase::ProcessPhase::ALL`],
329/// [`crate::signal::ProcessSignal::ALL`],
330/// [`crate::boundary::ConditionKind::ALL`],
331/// [`crate::lifetime::TeardownPolicy::ALL`],
332/// [`crate::lifetime::LifetimeKind::ALL`],
333/// [`crate::intent::IntentKind::ALL`],
334/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
335///
336/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
337/// requestor kinds become a TYPE rather than four `&'static str`
338/// literals at every author + docstring + fixture site. THEORY.md
339/// §V.1 — knowable platform; the closed-set view turns "which kinds
340/// does the substrate actually emit" from a grep job into a method
341/// the compiler enforces exhaustively at every dispatch site.
342#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
343#[closed_set(via = "as_str", generate_unknown, display)]
344pub enum RequestorKind {
345 /// GitHub pull-request webhook — `tatara-github-watcher` stamps
346 /// this on every allocation built from a `PullRequestEvent`.
347 GithubPr,
348 /// Operator-authored allocation — `feira allocation request …`
349 /// and any hand-crafted CR.
350 Manual,
351 /// Non-PR CI driver — a pipeline run that wants an ephemeral env
352 /// without an associated pull request.
353 CiRun,
354 /// Cron-style scheduled emitter — periodic allocation creation
355 /// (e.g. nightly drift detection).
356 Scheduled,
357}
358
359impl RequestorKind {
360 /// The closed set of substrate-emitted requestor kinds — single
361 /// source of truth that drives the [`Self::from_str`] decode sweep
362 /// AND any future enumeration consumer (pool-selector classifiers,
363 /// dashboard completion, `tatara-check` kind enumeration). Adding
364 /// a fifth variant (e.g. `Slack` → `"slack"`) lands at one `ALL`
365 /// entry + one `as_str` arm — exhaustively checked by the compiler
366 /// (the `[Self; 4]` array literal forces the arity) AND by the
367 /// per-variant truth-table tests below.
368 pub const ALL: [Self; 4] = [Self::GithubPr, Self::Manual, Self::CiRun, Self::Scheduled];
369
370 /// Canonical kebab-case wire-format kind — the literal that lands
371 /// in [`Requestor::kind`] when this variant authors the request.
372 /// Pinned to four byte-exact strings the substrate has already
373 /// published (the `tatara-github-watcher` factory, the operator
374 /// fixtures in this file, the `PoolSelector.kinds` filter, the
375 /// CRD printcolumns) — renaming any one is a wire-format change,
376 /// not a typed-internal refactor, and the
377 /// `requestor_kind_canonical_names_pinned` truth-table test fails
378 /// first to keep the substrate honest. Used by [`std::fmt::Display`]
379 /// (single source of truth) and as the `String` projection that
380 /// `From<RequestorKind> for String` ([`Self::into`]) composes so
381 /// emitters can spell `Requestor { kind: RequestorKind::GithubPr.into(), … }`
382 /// without re-typing the canonical literal at every author site.
383 #[must_use]
384 pub const fn as_str(self) -> &'static str {
385 match self {
386 Self::GithubPr => "github-pr",
387 Self::Manual => "manual",
388 Self::CiRun => "ci-run",
389 Self::Scheduled => "scheduled",
390 }
391 }
392}
393
394// `impl FromStr for RequestorKind` + `impl tatara_lisp::ClosedSet for
395// RequestorKind` + `impl std::fmt::Display for RequestorKind` are
396// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
397// declaration above. `label` delegates to the inherent
398// `RequestorKind::as_str` via `#[closed_set(via = "as_str")]` so the
399// kebab-case wire-format projection stays load-bearing (matches the
400// `tatara-github-watcher` factory + the CRD printcolumns + the
401// `PoolSelector.kinds` filter verbatim) while generic `T: ClosedSet`
402// consumers reach the STABLE workspace-wide name (`label`). The
403// `display` flag emits the `f.write_str(self.as_str())` delegation
404// block — the substrate-wide closed-set-enum idiom's third piece —
405// at the same proc-macro site rather than a hand-rolled
406// `fmt::Display` block per implementor.
407
408// `pub struct UnknownRequestorKind(pub String)` is generated by
409// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
410// on the enum declaration above. The auto-derived label `"requestor kind"`
411// matches the prior hand-rolled `#[error("unknown requestor kind: {0}")]`
412// verbatim — pinned generically by clause (5) of
413// `tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>()` (called
414// from `requestor_kind_is_well_formed_closed_set` in the test module).
415// Symmetric to every sibling `Unknown*` error in this crate (e.g.
416// [`UnknownAllocationPhase`], [`crate::receipt::UnknownReceiptKind`],
417// [`crate::phase::UnknownPhase`], [`crate::lifetime::UnknownTeardownPolicy`]).
418
419impl From<RequestorKind> for String {
420 /// Composes [`RequestorKind::as_str`] into an owned `String` so
421 /// every `impl Into<String>` API surface (the `kind:` field
422 /// initializer on [`Requestor`] most notably) accepts the typed
423 /// variant transparently — the call site stays
424 /// `kind: RequestorKind::GithubPr.into()` and the typed → wire
425 /// bridge runs through ONE place. Sibling shape to
426 /// [`crate::receipt::ReceiptKind`]'s `From for String`.
427 fn from(k: RequestorKind) -> Self {
428 k.as_str().to_owned()
429 }
430}
431
432impl From<RequestorKind> for &'static str {
433 fn from(k: RequestorKind) -> Self {
434 k.as_str()
435 }
436}
437
438/// `EphemeralAllocation.status` — observed allocation state.
439#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
440#[serde(rename_all = "camelCase")]
441pub struct AllocationStatus {
442 /// Current lifecycle phase.
443 #[serde(default)]
444 pub phase: AllocationPhase,
445
446 /// When the phase last changed.
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub phase_since: Option<DateTime<Utc>>,
449
450 /// Pool that owns the matched member. Set as soon as routing
451 /// resolves; not cleared on release (audit trail).
452 #[serde(default, skip_serializing_if = "Option::is_none")]
453 pub bound_pool: Option<AllocationRef>,
454
455 /// The Process backing this allocation, if Bound.
456 #[serde(default, skip_serializing_if = "Option::is_none")]
457 pub assigned_process: Option<AllocationRef>,
458
459 /// When the allocation was matched to a Process.
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub allocated_at: Option<DateTime<Utc>>,
462
463 /// Wall-clock expiry derived from `spec.ttl` + `allocated_at`.
464 /// The pool reconciler force-returns the member at this point.
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub expires_at: Option<DateTime<Utc>>,
467
468 /// Operator-visible message.
469 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub message: Option<String>,
471
472 /// Standard Conditions.
473 ///
474 /// The empty case is skipped at serialization so a merge-patch
475 /// body built from a caller-supplied [`AllocationStatus`] whose
476 /// `conditions` slot has not been touched does NOT emit
477 /// `"conditions": []` on the wire — under RFC-7396 JSON Merge
478 /// Patch (the shape `Patch::Merge` sends) an empty array
479 /// REPLACES the persisted list rather than merges into it, so a
480 /// controller round-trip that reused a scratch `AllocationStatus`
481 /// as a patch body would silently clobber whatever conditions the
482 /// prior status carried. Peer to `phase_since` /
483 /// `bound_pool` / `assigned_process` / `allocated_at` /
484 /// `expires_at` above, each already skip-serialized on its
485 /// [`Default`]-equivalent variant.
486 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 pub conditions: Vec<AllocationCondition>,
488}
489
490impl AllocationStatus {
491 /// Substrate composer for a phase-transition [`AllocationStatus`]
492 /// seed: stamps the THREE always-present slots (`phase` +
493 /// `phase_since = Some(now)` + `message = Some(<supplied>)`) and
494 /// defaults every other slot (`bound_pool` / `assigned_process` /
495 /// `allocated_at` / `expires_at` = `None`, `conditions = vec![]`).
496 /// Caller-branches attach the extra slots via struct-update
497 /// syntax onto the seed.
498 ///
499 /// Pre-lift the 4-slot phase-transition seed
500 /// ```rust,ignore
501 /// json!({
502 /// "status": {
503 /// "phase": <AllocationPhase-variant>,
504 /// "phaseSince": Utc::now(),
505 /// "message": "<transition-reason>",
506 /// …optional caller-attached slots…
507 /// }
508 /// })
509 /// ```
510 /// was hand-authored at FOUR sites past the ★★ PRIME-DIRECTIVE
511 /// ≥ 2 duplication threshold in
512 /// `tatara-pool-reconciler::controller_allocation::reconcile_inner`,
513 /// each restating the SAME `phase + phase_since + message` invariant
514 /// triplet on a different [`AllocationPhase`] variant:
515 /// * `AllocationDecision::NoMatchingPool` — the "no Pool selector
516 /// matched this Requestor" fallthrough
517 /// ([`AllocationPhase::NoMatchingPool`]).
518 /// * `AllocationDecision::Wait` — the "pool matched; no Free member
519 /// available" queued path
520 /// ([`AllocationPhase::Queued`]) with a `bound_pool` addition.
521 /// * `AllocationDecision::Bind` — the "bound to pool member"
522 /// allocation path ([`AllocationPhase::Bound`]) with
523 /// `bound_pool` + `assigned_process` + `allocated_at` +
524 /// `expires_at` additions.
525 /// * `AllocationDecision::Release` — the "released; pool reconciler
526 /// will return the member" release path
527 /// ([`AllocationPhase::Released`]) with `bound_pool` +
528 /// `assigned_process` additions.
529 ///
530 /// All four hand-authored the SAME `phaseSince: Utc::now()` stamp
531 /// alongside the phase transition, and all four spelled the
532 /// invariant triplet as bare JSON keys inside a `json!({...})`
533 /// literal — a fragile shape where any drift in the underlying
534 /// [`AllocationStatus`] field naming (a rename from `phaseSince`
535 /// to `phase_since` at the serde surface, a promotion of `message`
536 /// to a structured envelope) silently stops the JSON keys from
537 /// mapping to the typed struct's fields and the K8s API server
538 /// merges an ill-shaped patch. Post-lift the four callers build a
539 /// typed [`AllocationStatus`] via `AllocationStatus::transition`,
540 /// attach any branch-specific slots via struct-update syntax, and
541 /// wrap the result in `json!({ "status": s })` — the serde
542 /// `rename_all = "camelCase"` derive on [`AllocationStatus`] owns
543 /// the wire-shape composition, so a field rename lands at ONE
544 /// site (the derive) and every emit site inherits the upgrade
545 /// mechanically.
546 ///
547 /// Cross-CRD peer to [`crate::pool::PoolStatus::observed`] on the
548 /// same `<CRD>Status` substrate-composer axis — both primitives
549 /// stamp `phase_since = Some(now)` from a caller-supplied `now`
550 /// timestamp so the composer stays clock-injectable rather than
551 /// implicitly reading wall time, and both close every optional slot
552 /// with its [`Default`]-equivalent variant so a future slot
553 /// addition on either status shape plugs into the composer at ONE
554 /// site and every downstream emit site inherits the new slot
555 /// mechanically.
556 ///
557 /// Cross-CRD peer to the `tatara-reconciler::patch::phase_status_msg`
558 /// primitive on the (CRD × phase-transition-with-message) axis —
559 /// both primitives own the three-slot `phase + phase_since +
560 /// message` invariant on their respective CRDs' status subresource,
561 /// and both accept `impl Into<String>` for the message so the
562 /// callsite carries `&'static str` literal reasons and
563 /// `format!(...)`-owned strings without widening the signature.
564 ///
565 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
566 /// the 4-slot phase-transition status-seed incantation recurred at
567 /// four hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
568 /// duplication trigger, and is lifted to ONE owner here).
569 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
570 /// the pins bind the three always-present slots + the
571 /// [`Default`]-defaulted rest + byte-identical parity with the
572 /// pre-lift `json!({...})` triplet through serde round-trip, so a
573 /// regression that drifted any surface at
574 /// `tests::allocation_status_transition_*` rather than as silent
575 /// operator-visible skew between the four allocation-decision
576 /// patch sites).
577 #[must_use]
578 pub fn transition(
579 phase: AllocationPhase,
580 message: impl Into<String>,
581 now: DateTime<Utc>,
582 ) -> Self {
583 Self {
584 phase,
585 phase_since: Some(now),
586 message: Some(message.into()),
587 ..Default::default()
588 }
589 }
590
591 /// Substrate composer for a phase-transition [`AllocationStatus`]
592 /// seed whose `bound_pool` + `assigned_process` axis-pair is
593 /// stamped alongside the base [`Self::transition`] triplet
594 /// (`phase` + `phase_since = Some(now)` + `message =
595 /// Some(<supplied>)`). Every other slot lands at its
596 /// [`Default`]-equivalent variant so a caller-branch that attaches
597 /// an optional slot via struct-update syntax (a `Bind` arm's
598 /// `allocated_at` / `expires_at` addenda, say) does not silently
599 /// inherit a pre-populated non-`None` value.
600 ///
601 /// Pre-lift the `bound_pool: Some(pool)` + `assigned_process:
602 /// Some(AllocationRef::new(name, ns))` pair rode struct-update
603 /// syntax onto [`Self::transition`] at TWO sites past the ★★
604 /// PRIME-DIRECTIVE ≥ 2 duplication threshold in
605 /// `tatara-pool-reconciler::controller_allocation::reconcile_inner`
606 /// — the `AllocationDecision::Bind` arm ([`AllocationPhase::Bound`]
607 /// with two extra `allocated_at` / `expires_at` addenda) and the
608 /// `AllocationDecision::Release` arm ([`AllocationPhase::Released`]
609 /// with no addenda). Both restated the SAME pair-of-`Some`-slot
610 /// invariant against the SAME struct-update seed and funneled the
611 /// resulting body through the SAME `patch_status` call on
612 /// `Api<EphemeralAllocation>`. Post-lift both callers reach the
613 /// pair through ONE substrate composer; a future normalization on
614 /// the bound-set axis (a symmetry gate that the assigned_process's
615 /// namespace matches the bound_pool's namespace, a canonicalization
616 /// that closes the pair against a stale audit record, a
617 /// backwards-compatibility rename of either slot at the serde
618 /// surface) lands at ONE substrate site rather than at each
619 /// callsite in the two-arm allocation reconciler.
620 ///
621 /// Composes atop [`Self::transition`] so any future evolution to
622 /// the base three-slot invariant triplet (a `phase_since` rename,
623 /// a `message` promotion to a structured envelope, a fourth
624 /// always-stamped diagnostic slot) reaches this composer through
625 /// ONE substrate site and both consumers inherit the upgrade
626 /// mechanically. Sibling composition discipline to
627 /// [`crate::pool::PoolStatus::observed`]'s `state_count_fanout` +
628 /// `Utc::now()` fold — the compound composer names its axis + calls
629 /// the substrate primitive on the invariant it wraps rather than
630 /// restating the wrapped shape inline.
631 ///
632 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
633 /// the `bound_pool + assigned_process` pair recurred at two hand-
634 /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
635 /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
636 /// invariant 5 (composition preserves proofs — the pins bind the
637 /// pair + the composed base triplet + byte-identical parity with
638 /// the pre-lift struct-update shape through serde round-trip, so a
639 /// regression that drifted any surface at
640 /// `tests::allocation_status_bound_transition_*` rather than as
641 /// silent operator-visible skew between the two Bind / Release
642 /// patch sites).
643 #[must_use]
644 pub fn bound_transition(
645 phase: AllocationPhase,
646 message: impl Into<String>,
647 now: DateTime<Utc>,
648 bound_pool: AllocationRef,
649 assigned_process: AllocationRef,
650 ) -> Self {
651 Self {
652 bound_pool: Some(bound_pool),
653 assigned_process: Some(assigned_process),
654 ..Self::transition(phase, message, now)
655 }
656 }
657
658 /// Wall-clock-anchored peer of [`Self::transition`] — reads
659 /// `Utc::now()` at call time and forwards it into the substrate
660 /// composer's `now` slot.
661 ///
662 /// Pre-lift the 3-arg [`Self::transition`] chain fed by an inline
663 /// `Utc::now()` third argument was hand-authored at TWO production
664 /// sites in `tatara-pool-reconciler::controller_allocation::
665 /// reconcile_inner` past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
666 /// threshold:
667 ///
668 /// * The `AllocationDecision::NoMatchingPool` arm — status seed at
669 /// `AllocationPhase::NoMatchingPool` (no addenda; the composer's
670 /// seed is patched verbatim).
671 /// * The `AllocationDecision::Wait { pool }` arm — status seed at
672 /// `AllocationPhase::Queued` inside a `..Self::transition(...)`
673 /// struct-update that adds `bound_pool: Some(pool)`.
674 ///
675 /// Both sites walked the SAME 3-arg call with the SAME
676 /// `chrono::Utc::now()` third argument — the wall-clock projection
677 /// had no per-callsite variation. Post-lift both consumers share
678 /// ONE substrate owner for the wall-clock-at-tick projection; a
679 /// future clock swap (a monotonic clock cross-check, a per-
680 /// reconciler injected time source, a test-only override at the
681 /// production callsite via feature flag) lands at ONE substrate
682 /// function and every allocation-decision status-patch site
683 /// inherits the upgrade mechanically.
684 ///
685 /// The 3-arg [`Self::transition`] peer stays load-bearing for test
686 /// callers — the injected-`now` shape is what unit tests use to
687 /// drive the clock deterministically (every
688 /// `AllocationStatus::transition(phase, msg, anchor_time())` in
689 /// this module's own test suite reads that surface). This peer is
690 /// production-only: pinning the wall-clock at the substrate site
691 /// means no test can accidentally consume it without the
692 /// deterministic-clock injection that makes the test meaningful.
693 ///
694 /// Sibling of [`crate::pool::PoolStatus::observed_now`] on the
695 /// (`<CRD>Status` substrate composer × wall-clock-anchored peer)
696 /// axis — both primitives own the "read the wall clock at tick-
697 /// time" projection on a peer clock-injectable substrate composer
698 /// so the workspace's `<CRD>Status` composer family stays uniform
699 /// across `PoolStatus.observed` on the pool axis and
700 /// `AllocationStatus.transition` on the allocation axis. Peer to
701 /// [`crate::lifetime_clock::evaluate_now`] on the (typed pure-fn,
702 /// wall-clock-anchored peer) axis for the timed-decision family.
703 ///
704 /// # Invariants
705 ///
706 /// - **Same shape:** returns the SAME [`AllocationStatus`] the
707 /// 3-arg [`Self::transition`] returns when passed
708 /// `chrono::Utc::now()` as the third argument. This is a
709 /// delegation, not a re-implementation.
710 /// - **Wall-clock read once:** `Utc::now()` is called exactly ONCE
711 /// per invocation, at the primitive's body, so a future consumer
712 /// that chains two `transition_now` calls back-to-back still sees
713 /// monotonic `now` reads (each call reads a fresh instant, not a
714 /// cached one) — matches the pre-lift shape where each of the
715 /// two status-patch sites computed its own `chrono::Utc::now()`
716 /// at its own line.
717 ///
718 /// # `#[must_use]`
719 ///
720 /// Every consumer feeds the returned [`AllocationStatus`] into
721 /// `tatara_process::patch::merge_status(&alloc_api, &name, &<status>)`
722 /// or a peer status-patch call. Dropping the return means the
723 /// transition composed for no observable reason — the attribute
724 /// surfaces that as a warning at every call site.
725 ///
726 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
727 /// the 3-arg call with `chrono::Utc::now()` as the third argument
728 /// recurred at 2 hand-authored sites past the ★★ PRIME-DIRECTIVE
729 /// ≥ 2 duplication trigger, lifted onto the ONE workspace-wide
730 /// substrate owner here). THEORY.md §II.1 invariant 5 (composition
731 /// preserves proofs — the wall-clock projection lives at ONE site
732 /// so a future clock swap reaches both consumers through one edit).
733 #[must_use]
734 pub fn transition_now(phase: AllocationPhase, message: impl Into<String>) -> Self {
735 Self::transition(phase, message, Utc::now())
736 }
737}
738
739/// Allocation lifecycle phase.
740///
741/// Sibling closed-set lifts on the same `EphemeralAllocation` /
742/// `EphemeralPool` axis: [`crate::pool::ReplacementPolicy::ALL`],
743/// [`crate::pool::ReturnPolicy::ALL`]. Sibling closed-sets on the
744/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`],
745/// [`crate::lifetime::LifetimeKind::ALL`],
746/// [`crate::boundary::ConditionKind::ALL`],
747/// [`crate::intent::IntentKind::ALL`],
748/// [`crate::phase::ProcessPhase::ALL`],
749/// [`crate::signal::ProcessSignal::ALL`].
750#[derive(
751 Clone,
752 Copy,
753 Debug,
754 PartialEq,
755 Eq,
756 Hash,
757 Serialize,
758 Deserialize,
759 JsonSchema,
760 tatara_closed_set::DeriveClosedSet,
761)]
762#[serde(rename_all = "PascalCase")]
763#[closed_set(via = "as_str", generate_unknown, display)]
764pub enum AllocationPhase {
765 /// Admitted; pool selector matching not yet attempted.
766 Pending,
767 /// Routed to a pool but no `Free` member is available — queued.
768 Queued,
769 /// A pool member has been assigned + transitioned to Allocated.
770 Bound,
771 /// `expires_at` reached or requestor deleted; member is returning.
772 Releasing,
773 /// Released; the allocation is a permanent audit record.
774 Released,
775 /// No pool selector matched. The reconciler will retry on each
776 /// pool spec update; surfaced in status so operators see why.
777 NoMatchingPool,
778 /// Pool refused (e.g., `max_size` reached and no member can be
779 /// freed) — operator intervention needed.
780 Failed,
781}
782
783impl Default for AllocationPhase {
784 fn default() -> Self {
785 Self::Pending
786 }
787}
788
789impl AllocationPhase {
790 /// The closed set of allocation phases — single source of truth
791 /// that drives the `as_str` / Display / `FromStr` triad AND the
792 /// `is_terminal` / `needs_pool_routing` predicate pair the
793 /// allocation reconciler's observe/decide split dispatches on.
794 /// Adding an eighth variant lands at one `ALL` entry + one
795 /// `as_str` arm + one arm per predicate — exhaustively checked by
796 /// the compiler (the `[Self; 7]` array literal forces the arity)
797 /// and by the implication test
798 /// (`allocation_phase_terminal_excludes_routing`) so a new
799 /// variant can't claim to be both terminal AND routing-eligible.
800 pub const ALL: [Self; 7] = [
801 Self::Pending,
802 Self::Queued,
803 Self::Bound,
804 Self::Releasing,
805 Self::Released,
806 Self::NoMatchingPool,
807 Self::Failed,
808 ];
809
810 /// Canonical PascalCase wire-format projection — matches the
811 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
812 /// `enum:` enumeration the allocation reconciler stamps on the
813 /// `ephemeralallocations.tatara.pleme.io` schema. Pinned by
814 /// `allocation_phase_as_str_matches_serde` so a variant rename
815 /// can't drift between the typed surface, the CRD enum, the YAML
816 /// wire format AND any operator-facing diagnostic composed via
817 /// Display rather than a hard-coded literal that would silently
818 /// rot.
819 pub const fn as_str(self) -> &'static str {
820 match self {
821 Self::Pending => "Pending",
822 Self::Queued => "Queued",
823 Self::Bound => "Bound",
824 Self::Releasing => "Releasing",
825 Self::Released => "Released",
826 Self::NoMatchingPool => "NoMatchingPool",
827 Self::Failed => "Failed",
828 }
829 }
830
831 /// True iff the allocation has reached an absorbing state —
832 /// `Released` (clean audit record) or `Failed` (pool refused;
833 /// operator intervention needed). The allocation reconciler
834 /// short-circuits both phases to `NoOp` rather than re-running
835 /// the routing / heartbeat ladder against a settled record.
836 ///
837 /// Closed-set match (not `matches!`) so a future variant
838 /// triggers the compiler's exhaustiveness check at this site
839 /// rather than silently defaulting to `false` and letting a new
840 /// terminal phase fall through into pool rebinding. Paired with
841 /// `needs_pool_routing` they form the two-axis projection
842 /// `allocation_decide::AllocationConvergence::decide` matches
843 /// against — the impossible bucket `(true, true)` is pinned
844 /// empty by `allocation_phase_terminal_excludes_routing`.
845 pub const fn is_terminal(self) -> bool {
846 match self {
847 Self::Released | Self::Failed => true,
848 Self::Pending | Self::Queued | Self::Bound | Self::Releasing | Self::NoMatchingPool => {
849 false
850 }
851 }
852 }
853
854 /// True iff the allocation is on the routing path — the
855 /// reconciler still needs to resolve a target pool + look up a
856 /// free member. `Pending` (just admitted), `Queued` (matched
857 /// pool was full last tick), and `NoMatchingPool` (no selector
858 /// matched yet; retry on pool spec updates) all live here. The
859 /// settled non-terminal phases `Bound` (already matched) and
860 /// `Releasing` (being torn down) don't — they short-circuit to
861 /// the heartbeat / release ladder without re-resolving the pool.
862 ///
863 /// Closed-set match (not `matches!`) — same exhaustiveness
864 /// discipline as [`Self::is_terminal`]. Lifts the open-coded
865 /// `phase != Released && phase != Bound` gate that
866 /// `allocation_decide::AllocationConvergenceCtx::observe` used
867 /// to predicate pool resolution on, AND closes the latent gap
868 /// where `Failed` / `Releasing` (neither `Released` nor `Bound`)
869 /// would slip through to the routing branch — a `Failed`
870 /// allocation without a deletion timestamp could be silently
871 /// rebound to a fresh pool member, which is the opposite of
872 /// "operator intervention needed."
873 pub const fn needs_pool_routing(self) -> bool {
874 match self {
875 Self::Pending | Self::Queued | Self::NoMatchingPool => true,
876 Self::Bound | Self::Releasing | Self::Released | Self::Failed => false,
877 }
878 }
879}
880
881// `impl FromStr for AllocationPhase` + `impl tatara_lisp::ClosedSet for
882// AllocationPhase` + `impl std::fmt::Display for AllocationPhase` are
883// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
884// declaration above. `label` delegates to the inherent
885// `AllocationPhase::as_str` via `#[closed_set(via = "as_str")]` so the
886// PascalCase wire-format projection stays load-bearing (matches the serde
887// rename + the CRD `enum:` enumeration the allocation reconciler stamps
888// on the `ephemeralallocations.tatara.pleme.io` schema verbatim) while
889// generic `T: ClosedSet` consumers reach the STABLE workspace-wide name
890// (`label`). The `display` flag emits the `f.write_str(self.as_str())`
891// delegation block at the same proc-macro site rather than a
892// hand-rolled `fmt::Display` block per implementor.
893
894// `pub struct UnknownAllocationPhase(pub String)` is generated by
895// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
896// on the enum declaration above. The auto-derived label `"allocation phase"`
897// matches the prior hand-rolled `#[error("unknown allocation phase: {0}")]`
898// verbatim — pinned generically by clause (5) of
899// `tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>()` (called
900// from `allocation_phase_is_well_formed_closed_set` in the test module).
901// Symmetric to [`crate::pool::UnknownReplacementPolicy`],
902// [`crate::pool::UnknownReturnPolicy`],
903// [`crate::lifetime::UnknownTeardownPolicy`],
904// [`crate::boundary::UnknownConditionKind`], and
905// [`crate::phase::UnknownPhase`].
906
907/// Allocation Condition (same shape as PoolCondition for downstream
908/// uniformity).
909#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
910#[serde(rename_all = "camelCase")]
911pub struct AllocationCondition {
912 pub type_: String,
913 pub status: String,
914 pub reason: String,
915 pub message: String,
916 pub last_transition_time: DateTime<Utc>,
917}
918
919impl EphemeralAllocation {
920 /// The copy-form status-projection primitive on the phase axis:
921 /// returns the [`AllocationPhase`] the pool reconciler currently
922 /// persists at `status.phase`, wrapped in an `Option` so the
923 /// missing-`status` corner collapses to `None` — the ONE-liner
924 /// collapse of the paired `self.status.as_ref().map(|s| s.phase)`
925 /// incantation the pool reconciler's `AllocationConvergenceCtx::
926 /// observe` restated by hand pre-lift.
927 ///
928 /// Cross-CRD peer to [`crate::prelude::Process::observed_phase`]
929 /// on the (CRD × phase-slot × observed-status) axis pair — both
930 /// primitives walk the identical `.status.as_ref().map(|s| s.
931 /// phase)` shape, differing only in the `Phase` type projected
932 /// ([`AllocationPhase`] vs [`crate::phase::ProcessPhase`]). The
933 /// substrate now owns the borrow-form `.status.as_ref().map(|s|
934 /// s.phase)` chain axis-uniformly across the two `Phase`-having
935 /// CRDs so a future normalization (a generation-filter that
936 /// returns `None` for a phase stamped with a stale
937 /// `metadata.generation`, a staleness gate that drops a phase
938 /// whose observing `phase_since` predates a reconcile deadline,
939 /// a canonicalization pass that maps a phase outside the CRD's
940 /// closed set to `None`) lands at ONE substrate method per CRD
941 /// rather than being restated at every observer.
942 #[must_use]
943 pub fn observed_phase(&self) -> Option<AllocationPhase> {
944 self.status.as_ref().map(|s| s.phase)
945 }
946
947 /// The copy-form status-projection primitive on the phase axis
948 /// with the [`AllocationPhase::Pending`] sink applied — the
949 /// ONE-liner collapse of the paired `self.observed_phase().
950 /// unwrap_or(AllocationPhase::Pending)` incantation the pool
951 /// reconciler's `AllocationConvergenceCtx::observe` restated by
952 /// hand pre-lift as a 5-line `.status.as_ref().map(|s| s.phase).
953 /// unwrap_or(AllocationPhase::Pending)` chain.
954 ///
955 /// Pre-lift the chain sat at [`tatara-pool-reconciler::
956 /// allocation_decide::AllocationConvergenceCtx::observe`]'s
957 /// `phase` seed. Cross-CRD peer to [`crate::prelude::Process::
958 /// observed_phase_or_pending`] on the (CRD × phase-slot × sink)
959 /// axis pair — both primitives close the missing-`status`
960 /// corner with each CRD's respective [`Default`]-equivalent
961 /// `Pending` variant, and both compose on top of their peer
962 /// [`Self::observed_phase`] / [`crate::prelude::Process::
963 /// observed_phase`] borrow-form projections so a future
964 /// normalization at the underlying `observed_phase` primitive
965 /// reaches both the raw-`Option` accessor and the `Pending`-
966 /// sinked composer through the SAME upstream body.
967 ///
968 /// The [`AllocationPhase::Pending`] sink is load-bearing as the
969 /// "not yet observed" default — the pool reconciler's typed
970 /// `AllocationPhase::needs_pool_routing` predicate returns
971 /// `true` for `Pending`, so a freshly-admitted Allocation whose
972 /// pool reconciler has not yet stamped a `.status` slot reads
973 /// as `Pending` and immediately enters the routing ladder,
974 /// matching the pre-lift `AllocationPhase::Pending` fallback
975 /// semantics verbatim.
976 ///
977 /// Theory anchor: THEORY.md §VI.1 (generation over composition
978 /// — the two-link `.status.as_ref().map(|s| s.phase).unwrap_or
979 /// (AllocationPhase::Pending)` chain recurred at both the
980 /// [`crate::prelude::Process`] site (already lifted onto
981 /// [`crate::prelude::Process::observed_phase_or_pending`]) AND
982 /// the [`EphemeralAllocation`] site by hand, i.e. the SHAPE
983 /// itself recurs past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
984 /// trigger, and is lifted to ONE owner per CRD here). THEORY.md
985 /// §II.1 invariant 5 (composition preserves proofs — the pins
986 /// bind the missing-`status` sink to `Pending` + populated-
987 /// status pass-through + every [`AllocationPhase`] variant
988 /// round-trip + byte-identical parity with the pre-lift
989 /// two-link chain + cross-CRD peer coherence with
990 /// [`crate::prelude::Process::observed_phase_or_pending`], so
991 /// a regression that drifted any surface at
992 /// `tests::observed_phase_*` rather than as silent operator-
993 /// facing skew between the allocation observer's routing seed
994 /// and the Process observer's dispatch seed).
995 #[must_use]
996 pub fn observed_phase_or_pending(&self) -> AllocationPhase {
997 self.observed_phase().unwrap_or(AllocationPhase::Pending)
998 }
999
1000 /// The borrow-form status-projection primitive on the bound-pool
1001 /// axis: returns the [`AllocationRef`] the pool reconciler
1002 /// currently persists at `status.bound_pool` (name + namespace of
1003 /// the pool that owns the matched member), with the
1004 /// missing-`status` corner AND the empty-slot corner BOTH
1005 /// collapsed to `None` — the ONE-liner collapse of the paired
1006 /// `self.status.as_ref().and_then(|s| s.bound_pool.<clone|as_ref>())`
1007 /// incantation the pool reconciler's `AllocationConvergenceCtx::
1008 /// observe` restated by hand pre-lift.
1009 ///
1010 /// Cross-CRD peer to [`crate::prelude::Process::observed_identity`]
1011 /// on the (CRD × structured-record-slot × borrow-form) axis pair
1012 /// — both primitives walk the identical `.status.as_ref()
1013 /// .and_then(|s| s.<slot>.as_ref())` shape, differing only in the
1014 /// record projected ([`AllocationRef`] here, [`crate::identity::
1015 /// Identity`] on `Process`). The substrate now owns the
1016 /// borrow-form `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
1017 /// chain on the second `structured-record` slot across the two
1018 /// `status`-having CRDs, so a future normalization step (a
1019 /// generation-filter that returns `None` for a bound-pool
1020 /// reference stamped with a stale `metadata.generation`, a
1021 /// canonicalization pass that rejects a malformed
1022 /// `(name, namespace)` pair, a cross-cluster reference-rewrite
1023 /// gate) lands at ONE substrate method per CRD rather than being
1024 /// restated at every observer.
1025 ///
1026 /// Return-form axis: `Option<&AllocationRef>` mirrors the
1027 /// borrow-first discipline of [`crate::prelude::Process::
1028 /// observed_identity`]. The lone pre-lift consumer
1029 /// ([`tatara-pool-reconciler::allocation_decide::
1030 /// AllocationConvergenceCtx::observe`]'s `bound_pool` seed) spelled
1031 /// the projection as `.and_then(|s| s.bound_pool.clone())` — an
1032 /// eager clone allocated inside every reconcile pass even when the
1033 /// downstream branch (the Release-composition arm) needed only the
1034 /// borrow for the `.as_ref()` re-projection two lines later.
1035 /// Post-lift the consumer reaches the primitive borrow-first
1036 /// (`alloc.observed_bound_pool().cloned()`) and the empty-borrow
1037 /// corner clones nothing (`Option::cloned` on `None` is `None`);
1038 /// the composition point where the owned `AllocationRef` fallback
1039 /// is required (the `AllocationConvergenceCtx` snapshot slot,
1040 /// still `Option<AllocationRef>`-typed for serde stability) is the
1041 /// ONLY site that materializes an owned copy.
1042 ///
1043 /// The missing-`status` corner AND the populated-status-with-
1044 /// `bound_pool=None` corner BOTH collapse to `None` so
1045 /// `.is_some()` / `if let Some(_)` / `.cloned()` behave
1046 /// identically on an `EphemeralAllocation` whose status field is
1047 /// `None` and on one whose status carries an unpopulated
1048 /// `bound_pool` slot — matching what the pre-lift `.and_then(...)`
1049 /// chain produced. Consumers that need to tell those corners
1050 /// apart reach for [`Self::status`] directly, exactly as the
1051 /// existing peer accessors [`Self::observed_phase`] +
1052 /// [`Self::observed_phase_or_pending`] admit.
1053 ///
1054 /// Theory anchor: THEORY.md §VI.1 (generation over composition
1055 /// — the `.status.as_ref().and_then(|s| s.<structured-record>
1056 /// .<clone|as_ref>())` shape recurred as ONE hand-authored
1057 /// `.and_then(|s| s.bound_pool.clone())` chain in
1058 /// [`tatara-pool-reconciler::allocation_decide::
1059 /// AllocationConvergenceCtx::observe`] AND as the peer
1060 /// [`crate::prelude::Process::observed_identity`] primitive
1061 /// already owned on the `Process` CRD's `status.identity` slot,
1062 /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger at
1063 /// substrate-shape level. THEORY.md §II.1 invariant 5
1064 /// (composition preserves proofs — the pins bind the missing-
1065 /// `status` corner + the empty-`bound_pool`-slot corner + the
1066 /// borrow-form `&AllocationRef` lifetime + the zero-copy
1067 /// projection contract + byte-identical parity with the pre-lift
1068 /// `.and_then(|s| s.bound_pool.clone())` chain across the full
1069 /// corner set + cross-CRD peer coherence with
1070 /// [`crate::prelude::Process::observed_identity`], so a
1071 /// regression that drifted any surface at
1072 /// `tests::observed_bound_pool_*` rather than as silent operator-
1073 /// facing skew between the allocation observer's Release-
1074 /// composition seed and the Process observer's FORK-time
1075 /// identity seed on the SAME reconcile tick).
1076 #[must_use]
1077 pub fn observed_bound_pool(&self) -> Option<&AllocationRef> {
1078 self.status.as_ref().and_then(|s| s.bound_pool.as_ref())
1079 }
1080
1081 /// The copy-form status-projection primitive on the TTL-expiry axis:
1082 /// returns the wall-clock deadline the pool reconciler currently
1083 /// persists at `status.expires_at` (derived from `spec.ttl` +
1084 /// `allocated_at` at Bind time), wrapped in an `Option` so both the
1085 /// missing-`status` corner AND the populated-status-with-`expires_at
1086 /// =None` corner collapse to `None` — the ONE-liner collapse of the
1087 /// paired `self.status.as_ref().and_then(|s| s.expires_at)`
1088 /// incantation the pool reconciler's `AllocationConvergenceCtx::
1089 /// observe` restated by hand pre-lift.
1090 ///
1091 /// Same-CRD peer to [`Self::observed_phase`] on the (CRD × copy-form
1092 /// × status-slot) axis pair — both primitives walk the identical
1093 /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape,
1094 /// differing only in the record projected ([`DateTime<Utc>`] here,
1095 /// [`AllocationPhase`] on the phase axis) and in the outer combinator
1096 /// (`and_then` here because the persisted field is itself an
1097 /// `Option<DateTime<Utc>>`, `map` there because the persisted phase
1098 /// is bare). The substrate now owns the copy-form
1099 /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` chain
1100 /// axis-uniformly across every `Copy`-valued slot on
1101 /// `AllocationStatus`, so a future normalization (a clock-skew
1102 /// guard that drops an `expires_at` stamped before its owning
1103 /// allocation's observed `allocated_at`, a canonicalization pass
1104 /// that clamps a deadline to a monotonic upper bound, a stale-
1105 /// timestamp gate that returns `None` on an `expires_at` older than
1106 /// a controller-configured horizon) lands at ONE substrate method
1107 /// rather than being restated at every observer.
1108 ///
1109 /// Return-form axis: `Option<DateTime<Utc>>` mirrors the copy-first
1110 /// discipline of [`Self::observed_phase`]. The lone pre-lift consumer
1111 /// ([`tatara-pool-reconciler::allocation_decide::
1112 /// AllocationConvergenceCtx::observe`]'s `expires_at` seed) spelled
1113 /// the projection as `.status.as_ref().and_then(|s| s.expires_at)` —
1114 /// a 3-link hand-authored chain the observer walked on every
1115 /// reconcile pass. Post-lift the consumer reaches the primitive
1116 /// once and the whole missing-status + empty-slot corner cross
1117 /// collapses at the substrate rather than at the callsite.
1118 ///
1119 /// The missing-`status` corner AND the populated-status-with-
1120 /// `expires_at=None` corner BOTH collapse to `None` so
1121 /// `.is_some()` / `if let Some(_)` / any `>=` deadline comparison
1122 /// behave identically on an `EphemeralAllocation` whose status
1123 /// field is `None` and on one whose status carries an unpopulated
1124 /// `expires_at` slot — matching what the pre-lift `.and_then(...)`
1125 /// chain produced. Consumers that need to tell those corners apart
1126 /// reach for [`Self::status`] directly, exactly as the existing peer
1127 /// accessors [`Self::observed_phase`] +
1128 /// [`Self::observed_phase_or_pending`] admit.
1129 ///
1130 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1131 /// the `.status.as_ref().and_then(|s| s.<Copy-field>)` shape
1132 /// recurred as ONE hand-authored chain in
1133 /// [`tatara-pool-reconciler::allocation_decide::
1134 /// AllocationConvergenceCtx::observe`] AND as the copy-form peer
1135 /// [`Self::observed_phase`] primitive already owned on the same
1136 /// CRD's `status.phase` slot, past the substrate-shape recurrence
1137 /// trigger; the substrate now owns the third status-projection
1138 /// primitive on `EphemeralAllocation`, closing the copy-form family
1139 /// alongside the borrow-form [`Self::observed_bound_pool`]).
1140 /// THEORY.md §II.1 invariant 5 (composition preserves proofs — the
1141 /// pins bind the missing-`status` corner + the empty-`expires_at`-
1142 /// slot corner + the copy-form `DateTime<Utc>` return + byte-
1143 /// identical parity with the pre-lift `.and_then(|s| s.expires_at)`
1144 /// chain across the full corner set, so a regression that drifted
1145 /// any surface surfaces at `tests::observed_expires_at_*` rather
1146 /// than as silent operator-facing skew between the allocation
1147 /// observer's Release-composition TTL gate and any future consumer
1148 /// that reaches for the same slot).
1149 #[must_use]
1150 pub fn observed_expires_at(&self) -> Option<DateTime<Utc>> {
1151 self.status.as_ref().and_then(|s| s.expires_at)
1152 }
1153
1154 /// The namespaced-CRD constructor composer on the
1155 /// `EphemeralAllocation` axis: forwards `(name, spec)` to the
1156 /// kube-derived [`Self::new`] constructor + stamps
1157 /// `metadata.namespace` with the caller-supplied slot in ONE
1158 /// step. The ONE-liner collapse of the paired `let mut a =
1159 /// EphemeralAllocation::new(<name>, <spec>); a.meta_mut().
1160 /// namespace = Some(<ns>.into());` incantation every allocation-
1161 /// side emitter restated by hand pre-lift.
1162 ///
1163 /// Pre-lift the 2-line construct-then-set-namespace chain was
1164 /// hand-authored at TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2
1165 /// duplication threshold across TWO workspace crates, composing
1166 /// a namespaced `EphemeralAllocation` fixture / event from a
1167 /// `name` slot and an `AllocationSpec`:
1168 /// * `tatara-github-watcher::allocation_factory::build_allocation`
1169 /// — the production PR-webhook → `EphemeralAllocation` emitter
1170 /// at `alloc.meta_mut().namespace = Some(namespace.to_string
1171 /// ())`; stamps the ephemeral-pools namespace as part of the
1172 /// canonical opened / reopened / synchronize allocation shape.
1173 /// * `tatara-pool-reconciler::allocation_decide::tests::alloc` —
1174 /// the allocation-decision test fixture pinned to `"pools"`,
1175 /// sibling to the peer `pool` fixture in the same module that
1176 /// composes an `EphemeralPool` via [`crate::pool::EphemeralPool::
1177 /// new_in`] on the same `ns` slot value.
1178 ///
1179 /// Both sites walked the SAME 2-line chain and both wanted the
1180 /// `EphemeralAllocation` back with `metadata.namespace` stamped as
1181 /// `Some(<ns>.into())`. Post-lift each callsite reads
1182 /// `EphemeralAllocation::new_in(<name>, <ns>, <spec>)` and the
1183 /// produced value feeds the same downstream `Api::create(&pp,
1184 /// &alloc)` chain / test-battery input unchanged.
1185 ///
1186 /// The `impl Into<String>` at the `namespace` slot matches the
1187 /// sibling `impl Into<String>`-widening discipline the workspace's
1188 /// other namespaced-CRD-adjacent composers walk
1189 /// ([`crate::pool::PoolMember::unallocated`] on the
1190 /// `process_name` slot, [`crate::pool::AllocationRef::new`] on the
1191 /// `(name, namespace)` slot pair, [`Requestor::kind_only`] on the
1192 /// `kind` slot) and accepts BOTH `&'static str` (the majority
1193 /// pre-lift caller shape) AND owned `String` at the SAME
1194 /// signature.
1195 ///
1196 /// Peer to [`crate::pool::EphemeralPool::new_in`] on the sister
1197 /// `EphemeralPool` CRD — the two primitives partition the
1198 /// namespaced-CRD-constructor family axis for the two pool-
1199 /// adjacent CRDs the workspace stamps at reconciler fixture /
1200 /// GitHub-webhook-emitter time. A future normalization (a per-
1201 /// fleet virtual-cluster prefix rewrite on the `namespace` slot,
1202 /// a per-cluster canonical case-fold pass, a `generateName`
1203 /// fallback on the `name` slot, an operator-scoped default
1204 /// namespace for cluster-local test rigs, an audit-tag stamped
1205 /// on every fixture-emitted CRD for post-hoc grep discipline)
1206 /// lands at ONE primitive body per CRD and every downstream
1207 /// consumer inherits the upgrade mechanically.
1208 ///
1209 /// `#[must_use]` on the return keeps a caller from composing the
1210 /// namespaced value and dropping it un-passed to a `kube::Api`
1211 /// create call or a `Vec<EphemeralAllocation>` reconciler-input
1212 /// slot.
1213 ///
1214 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
1215 /// the 2-line construct-then-set-namespace chain recurred at
1216 /// TWO hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
1217 /// duplication trigger, spanning two workspace crates, and is
1218 /// lifted to ONE substrate owner here). THEORY.md §II.1
1219 /// invariant 5 (composition preserves proofs — the pins below
1220 /// bind the (name-slot → metadata.name, ns-slot → metadata.
1221 /// namespace, spec-slot → spec) slot-projection triple + the
1222 /// byte-identical parity with the pre-lift 2-line chain across
1223 /// the two representative `impl Into<String>` value shapes
1224 /// (`&'static str` and owned `String`) + the sibling-composer
1225 /// coherence with [`Self::new`]).
1226 #[must_use]
1227 pub fn new_in(name: &str, namespace: impl Into<String>, spec: AllocationSpec) -> Self {
1228 // Routes through the ONE substrate owner of the
1229 // `metadata.namespace` stamp — the [`crate::PlacedInNamespace`]
1230 // blanket-impl trait over `kube::Resource<DynamicType = ()>`.
1231 // Byte-identical to the pre-lift 3-line body
1232 // (`Self::new(name, spec); metadata.namespace = Some(namespace
1233 // .into())`); the trait-forwarding form collapses the mutation
1234 // duplication with the sibling per-CRD composer
1235 // [`crate::pool::EphemeralPool::new_in`] and with the
1236 // render-fixture site on `Process` that has no per-CRD
1237 // `new_in` sibling.
1238 use crate::PlacedInNamespace;
1239 Self::new(name, spec).in_namespace(namespace)
1240 }
1241}
1242
1243#[cfg(test)]
1244mod tests {
1245 // `FromStr` lives in scope at the test surface only — the derive
1246 // emits `impl ::core::str::FromStr` via the full path so the lib
1247 // body no longer reaches `FromStr` directly, but the cross-axis
1248 // sweeps + the verbatim-echo contract tests call
1249 // `AllocationPhase::from_str(bad)` / `bad.parse::<RequestorKind>()`.
1250 use std::str::FromStr;
1251
1252 use super::*;
1253
1254 #[test]
1255 fn requestor_minimum_shape_round_trips() {
1256 let r = Requestor {
1257 kind: "github-pr".into(),
1258 repo: Some("pleme-io/demo-app".into()),
1259 branch: Some("fix-something".into()),
1260 pr_number: Some(123),
1261 sha: Some("abc123def".into()),
1262 pr_labels: vec!["needs-ephemeral".into()],
1263 actor: Some("drzln".into()),
1264 };
1265 let yaml = serde_yaml::to_string(&r).unwrap();
1266 assert!(yaml.contains("kind: github-pr"));
1267 assert!(yaml.contains("prNumber: 123"));
1268 let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
1269 assert_eq!(back.kind, "github-pr");
1270 assert_eq!(back.pr_number, Some(123));
1271 }
1272
1273 #[test]
1274 fn allocation_status_defaults_pending() {
1275 let s = AllocationStatus::default();
1276 assert_eq!(s.phase, AllocationPhase::Pending);
1277 assert!(s.bound_pool.is_none());
1278 assert!(s.assigned_process.is_none());
1279 }
1280
1281 #[test]
1282 fn allocation_phase_round_trips_via_serde() {
1283 for p in [
1284 AllocationPhase::Pending,
1285 AllocationPhase::Queued,
1286 AllocationPhase::Bound,
1287 AllocationPhase::Releasing,
1288 AllocationPhase::Released,
1289 AllocationPhase::NoMatchingPool,
1290 AllocationPhase::Failed,
1291 ] {
1292 let s = serde_yaml::to_string(&p).unwrap();
1293 let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
1294 assert_eq!(back, p);
1295 }
1296 }
1297
1298 // ── closed-set algebra contracts for AllocationPhase
1299 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
1300
1301 /// `ALL` is the source of truth — pin its closure so a variant
1302 /// added without an `ALL` entry fails here via the uniqueness
1303 /// check before drifting `FromStr` or the sweep tests below. The
1304 /// arity is asserted by the `[Self; 7]` array type itself.
1305 ///
1306 /// Structural well-formedness of [`AllocationPhase`] as a
1307 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1308 /// testkit lift that pins all three structural invariants
1309 /// (`ALL` is non-empty, every variant round-trips through
1310 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1311 /// outside the closed set) at ONE call site. Replaces the hand-
1312 /// derived `allocation_phase_all_is_unique_and_complete` +
1313 /// `allocation_phase_roundtrip_via_as_str` + the empty-input arm
1314 /// of `unknown_allocation_phase_errors`. `FromStr` delegates to
1315 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
1316 /// helper exercises the same code path the allocation reconciler
1317 /// hits when parsing a CRD `enum:`-validated value back to the
1318 /// typed phase.
1319 #[test]
1320 fn allocation_phase_is_well_formed_closed_set() {
1321 tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>();
1322 }
1323
1324 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1325 /// output verbatim for every variant. A future variant rename
1326 /// (or an `as_str` arm typo) lands here at one site, instead of
1327 /// drifting between the typed surface, the CRD enum, the YAML
1328 /// wire format, and the operator-facing reason strings the
1329 /// reconciler stamps via Display.
1330 #[test]
1331 fn allocation_phase_as_str_matches_serde() {
1332 crate::tagged_union::assert_label_matches_serde_serialization::<AllocationPhase>();
1333 }
1334
1335 /// The Display impl IS `as_str` — pinning this lets future
1336 /// callers reach for either projection without drift.
1337 #[test]
1338 fn allocation_phase_display_matches_as_str() {
1339 crate::tagged_union::assert_display_matches_label::<AllocationPhase>();
1340 }
1341
1342 /// `FromStr` rejects strings that aren't in the canonical
1343 /// projection — lowercased / typo / unrelated — and the error
1344 /// echoes the input verbatim so the operator-facing diagnostic
1345 /// carries the offending value, not a normalized form. The
1346 /// empty-input arm is pinned by
1347 /// [`allocation_phase_is_well_formed_closed_set`] via the
1348 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1349 /// verbatim-echo contract on the [`UnknownAllocationPhase`]
1350 /// newtype, which the trait's `make_unknown` can't see.
1351 #[test]
1352 fn unknown_allocation_phase_errors() {
1353 for bad in [
1354 "pending",
1355 "BOUND",
1356 "no-matching-pool",
1357 "release",
1358 "failed_state",
1359 "Reaped",
1360 ] {
1361 let err = AllocationPhase::from_str(bad).unwrap_err();
1362 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1363 }
1364 }
1365
1366 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1367 /// documented per-variant disposition. `Released` + `Failed` are
1368 /// terminal (absorbing); `Pending` / `Queued` / `NoMatchingPool`
1369 /// need pool routing; `Bound` / `Releasing` are settled-but-not-
1370 /// terminal (heartbeat / release ladder).
1371 #[test]
1372 fn allocation_phase_predicate_truth_tables() {
1373 assert!(!AllocationPhase::Pending.is_terminal());
1374 assert!(AllocationPhase::Pending.needs_pool_routing());
1375
1376 assert!(!AllocationPhase::Queued.is_terminal());
1377 assert!(AllocationPhase::Queued.needs_pool_routing());
1378
1379 assert!(!AllocationPhase::Bound.is_terminal());
1380 assert!(!AllocationPhase::Bound.needs_pool_routing());
1381
1382 assert!(!AllocationPhase::Releasing.is_terminal());
1383 assert!(!AllocationPhase::Releasing.needs_pool_routing());
1384
1385 assert!(AllocationPhase::Released.is_terminal());
1386 assert!(!AllocationPhase::Released.needs_pool_routing());
1387
1388 assert!(!AllocationPhase::NoMatchingPool.is_terminal());
1389 assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
1390
1391 assert!(AllocationPhase::Failed.is_terminal());
1392 assert!(!AllocationPhase::Failed.needs_pool_routing());
1393 }
1394
1395 /// IMPLICATION CONTRACT: `is_terminal → !needs_pool_routing`. A
1396 /// terminal allocation cannot also be routing-eligible — that's
1397 /// the bug the typed projection closes (a `Failed` allocation
1398 /// that's neither `Released` nor `Bound` would otherwise slip
1399 /// through the open-coded gate in `observe` and try to rebind to
1400 /// a pool member). A future variant that flipped both predicates
1401 /// true would fail here, forcing the author to flip one or
1402 /// extend the consumer dispatch site in
1403 /// `tatara-pool-reconciler::allocation_decide` deliberately
1404 /// rather than letting an impossible state slip in.
1405 #[test]
1406 fn allocation_phase_terminal_excludes_routing() {
1407 for phase in AllocationPhase::ALL {
1408 assert!(
1409 !(phase.is_terminal() && phase.needs_pool_routing()),
1410 "{phase:?} is both terminal and routing-eligible",
1411 );
1412 }
1413 }
1414
1415 /// DEFAULT-AGREEMENT CONTRACT: `AllocationPhase::default()` is
1416 /// `Pending` — the entry state, neither terminal nor settled —
1417 /// and it lives on the routing path. A future default-variant
1418 /// rename without flipping the predicates fails here.
1419 #[test]
1420 fn allocation_phase_default_is_pending_and_routes() {
1421 let d = AllocationPhase::default();
1422 assert_eq!(d, AllocationPhase::Pending);
1423 assert!(!d.is_terminal());
1424 assert!(d.needs_pool_routing());
1425 }
1426
1427 // ── RequestorKind closed-set truth-table ─────────────────────────
1428
1429 /// Structural well-formedness of [`RequestorKind`] as a
1430 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1431 /// testkit lift that pins all three structural invariants
1432 /// (`ALL` is non-empty, every variant round-trips through
1433 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1434 /// outside the closed set) at ONE call site. Replaces the hand-
1435 /// derived `requestor_kind_all_enumerates_each_variant_exactly_once`
1436 /// + `requestor_kind_from_str_round_trips_canonical_names` + the
1437 /// empty-input arm of `requestor_kind_from_str_rejects_open_kinds`.
1438 /// `FromStr` delegates to
1439 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1440 /// exercises the same code path
1441 /// [`Requestor::known_kind`]'s `Option<RequestorKind>` collapse
1442 /// rides on when classifying inbound `Requestor.kind` strings. The
1443 /// arity is asserted by the `[Self; 4]` array type itself.
1444 #[test]
1445 fn requestor_kind_is_well_formed_closed_set() {
1446 tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>();
1447 }
1448
1449 /// Byte-exact wire-format pin — renaming any of these is a wire-
1450 /// format change (the `tatara-github-watcher` emitter, the CRD
1451 /// printcolumns, the `PoolSelector.kinds` filter strings, the
1452 /// per-test `kind: "…".into()` fixtures all depend on these
1453 /// literals), not a typed-internal refactor.
1454 #[test]
1455 fn requestor_kind_canonical_names_pinned() {
1456 assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
1457 assert_eq!(RequestorKind::Manual.as_str(), "manual");
1458 assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
1459 assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
1460 }
1461
1462 /// `FromStr` rejects strings that aren't in the canonical
1463 /// projection — lowercased-mismatch / typo / unrelated — and the
1464 /// error echoes the input verbatim so the operator-facing
1465 /// diagnostic carries the offending value, not a normalized form.
1466 /// The schema is open at the wire layer (operators MAY register
1467 /// new kinds and `Requestor::known_kind` collapses them to
1468 /// `None`), but the closed-set view is byte-exact. The empty-input
1469 /// arm is pinned by [`requestor_kind_is_well_formed_closed_set`]
1470 /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
1471 /// the verbatim-echo contract on the [`UnknownRequestorKind`]
1472 /// newtype, which the trait's `make_unknown` can't see.
1473 #[test]
1474 fn requestor_kind_from_str_rejects_open_kinds() {
1475 for bad in [
1476 "github_pr",
1477 "GithubPr",
1478 "operator-custom-kind",
1479 "ci_run",
1480 "Scheduled",
1481 ] {
1482 let err = bad.parse::<RequestorKind>().unwrap_err();
1483 assert_eq!(err, UnknownRequestorKind(bad.to_string()));
1484 }
1485 }
1486
1487 /// The Display impl IS `as_str` — pinning this lets future
1488 /// callers reach for either projection without drift (Display is
1489 /// what operator-facing diagnostics compose against).
1490 #[test]
1491 fn requestor_kind_display_delegates_to_as_str() {
1492 for k in RequestorKind::ALL {
1493 assert_eq!(format!("{k}"), k.as_str());
1494 }
1495 }
1496
1497 /// The `String` projection that `From<RequestorKind> for String`
1498 /// ([`RequestorKind::into`]) composes is byte-equal to `as_str`.
1499 /// This is the typed → wire bridge — emitters spell
1500 /// `kind: RequestorKind::GithubPr.into()` and the canonical
1501 /// literal is materialized at ONE place.
1502 #[test]
1503 fn requestor_kind_into_string_matches_as_str() {
1504 for k in RequestorKind::ALL {
1505 let s: String = k.into();
1506 assert_eq!(s, k.as_str());
1507 }
1508 }
1509
1510 /// The typed → wire → typed round-trip: composing a `Requestor`
1511 /// with `kind: RequestorKind::X.into()` produces an object whose
1512 /// `known_kind()` decodes back to `X`. Pins the bridge invariant
1513 /// at the `Requestor` boundary, not just at `RequestorKind`.
1514 #[test]
1515 fn known_kind_decodes_built_requestors() {
1516 for k in RequestorKind::ALL {
1517 // Routes through the ONE substrate composer
1518 // `Requestor::kind_only` — one of TEN pre-lift exact-match
1519 // sites past the ★★ PRIME-DIRECTIVE ≥ 2 threshold.
1520 let r = Requestor::kind_only(k);
1521 assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
1522 }
1523 }
1524
1525 /// Open-by-design: a custom operator-registered kind still
1526 /// stamps a valid `Requestor` (no schema rejection), it just
1527 /// doesn't project through the closed-set typed view. Mirrors
1528 /// `ReceiptEnvelope::known_kind`'s open-kind posture.
1529 #[test]
1530 fn known_kind_returns_none_for_open_kinds() {
1531 // Routes through the ONE substrate composer
1532 // `Requestor::kind_only` — one of TEN pre-lift exact-match
1533 // sites past the ★★ PRIME-DIRECTIVE ≥ 2 threshold.
1534 let r = Requestor::kind_only("operator-custom-kind");
1535 assert_eq!(r.known_kind(), None);
1536 }
1537
1538 /// The four canonical literals match every previously-published
1539 /// fixture / doc anchor in this crate — pinning the bridge to
1540 /// existing call sites so any drift fails here before the next
1541 /// release ships.
1542 #[test]
1543 fn requestor_kind_matches_existing_fixture_literals() {
1544 // The `requestor_minimum_shape_round_trips` fixture above
1545 // composes `kind: "github-pr".into()` verbatim.
1546 assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
1547 // The `allocation_spec_omits_optional_fields` fixture below
1548 // composes `kind: "manual".into()` verbatim.
1549 assert_eq!(RequestorKind::Manual.as_str(), "manual");
1550 }
1551
1552 // ─── Requestor::kind_only substrate pins ────────────────────────
1553 //
1554 // Fail-before-pass-after granularity: `Requestor::kind_only` did
1555 // not exist before this commit. The composer's job is to bind ONE
1556 // caller-varying slot (`kind`) and freeze the six-slot default
1557 // tail so a future addition to `Requestor` lands at ONE primitive
1558 // body rather than at every fixture / default-shape callsite.
1559 // Sibling to the `AplicacaoIntent::chart_only` pin family (the
1560 // 7-slot chart-pointer-only composer) and the `PoolSpec::with_template`
1561 // pin family (the 11-slot pool full-spec composer).
1562
1563 #[test]
1564 fn kind_only_binds_kind_slot_and_defaults_the_other_six() {
1565 // Positional-binding pin: the sole caller slot lands at
1566 // `kind`; every other slot lands at its safe empty default
1567 // (`None` / `vec![]`).
1568 let r = Requestor::kind_only("manual");
1569 assert_eq!(r.kind, "manual");
1570 assert!(r.repo.is_none());
1571 assert!(r.branch.is_none());
1572 assert!(r.pr_number.is_none());
1573 assert!(r.sha.is_none());
1574 assert!(r.pr_labels.is_empty());
1575 assert!(r.actor.is_none());
1576 }
1577
1578 #[test]
1579 fn kind_only_matches_hand_authored_pre_lift_struct_literal_shape() {
1580 // Byte-identity pin: every pre-lift `Requestor { kind: <lit>
1581 // .into(), repo: None, branch: None, pr_number: None, sha:
1582 // None, pr_labels: vec![], actor: None }` shape must
1583 // deserialize back to the same fixture composed via
1584 // `kind_only`. Sweeps the two families every callsite used
1585 // (`"manual"` — the operator-authored fixture at seven
1586 // sites; `"github-pr"` — the github-webhook fixture at three
1587 // sites) plus one open-kind sample (`"operator-custom-kind"` —
1588 // the `known_kind_returns_none_for_open_kinds` open-kind pin)
1589 // so any drift between the primitive and the pre-lift shape
1590 // surfaces at ONE pin rather than as silent fixture skew at
1591 // ten downstream consumers.
1592 for kind in ["manual", "github-pr", "operator-custom-kind"] {
1593 let via_primitive = Requestor::kind_only(kind);
1594 let hand_authored = Requestor {
1595 kind: kind.into(),
1596 repo: None,
1597 branch: None,
1598 pr_number: None,
1599 sha: None,
1600 pr_labels: vec![],
1601 actor: None,
1602 };
1603 let via_yaml = serde_yaml::to_string(&via_primitive).unwrap();
1604 let hand_yaml = serde_yaml::to_string(&hand_authored).unwrap();
1605 assert_eq!(
1606 via_yaml, hand_yaml,
1607 "kind_only({kind:?}) must be YAML-identical to the pre-lift struct literal"
1608 );
1609 }
1610 }
1611
1612 #[test]
1613 fn kind_only_accepts_string_and_str_and_requestor_kind_uniformly() {
1614 // `impl Into<String>` symmetry across the three caller shapes
1615 // pre-lift authors used verbatim: `&'static str` (`"manual"`),
1616 // owned `String` (from a formatted context), and
1617 // [`RequestorKind`] (via the `From<RequestorKind> for String`
1618 // bridge exercised at `known_kind_decodes_built_requestors`).
1619 let from_str_literal = Requestor::kind_only("manual");
1620 let from_owned_string = Requestor::kind_only(String::from("manual"));
1621 let from_typed_variant = Requestor::kind_only(RequestorKind::Manual);
1622 assert_eq!(from_str_literal.kind, "manual");
1623 assert_eq!(from_owned_string.kind, "manual");
1624 assert_eq!(from_typed_variant.kind, "manual");
1625 }
1626
1627 #[test]
1628 fn kind_only_composes_downstream_through_known_kind_projection() {
1629 // Cross-primitive coherence pin: every substrate-emitted
1630 // `RequestorKind` variant round-trips through `kind_only` +
1631 // `known_kind` back to the same typed variant. Byte-identical
1632 // to the `known_kind_decodes_built_requestors` sweep the
1633 // primitive replaced — pins the primitive as the composer the
1634 // typed decoder sees the SAME wire shape from.
1635 for k in RequestorKind::ALL {
1636 let r = Requestor::kind_only(k);
1637 assert_eq!(
1638 r.known_kind(),
1639 Some(k),
1640 "kind_only({k:?}).known_kind() must round-trip to Some({k:?})"
1641 );
1642 }
1643 }
1644
1645 // Per-implementor `unknown_X_message_matches_substrate_convention`
1646 // tests removed — clause (5) of
1647 // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
1648 // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
1649 // generically (called above on `RequestorKind` /
1650 // `AllocationPhase` through their `*_is_well_formed_closed_set`
1651 // sites). The `SET_LABEL` projection is pinned independently by
1652 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
1653 // together the two contracts guarantee the operator-facing
1654 // diagnostic without needing per-enum literal pins.
1655
1656 // ─── EphemeralAllocation::observed_phase* substrate pins ────────
1657 //
1658 // Fail-before-pass-after granularity: neither `observed_phase` nor
1659 // `observed_phase_or_pending` existed before this commit, so each
1660 // pin fails to compile until the corresponding inherent method
1661 // lands. Post-lift the pins bind the missing-`status` corner + the
1662 // populated-status pass-through + byte-identical parity with the
1663 // pre-lift 5-line `.status.as_ref().map(|s| s.phase).unwrap_or
1664 // (AllocationPhase::Pending)` chain the pool reconciler's
1665 // `AllocationConvergenceCtx::observe` walked. Cross-CRD peer
1666 // coherence with `Process::observed_phase_or_pending` is pinned
1667 // by the `_matches_process_peer_shape` sweep at the tail.
1668
1669 fn alloc_with_phase(phase: AllocationPhase) -> EphemeralAllocation {
1670 // AllocationSpec rides through the ONE substrate composer
1671 // `AllocationSpec::requestor_only`; the inner Requestor rides
1672 // through the peer composer `Requestor::kind_only`. Nine pre-
1673 // lift exact-match `AllocationSpec { pool_ref: None,
1674 // requestor: <r>, ttl: None, note: None }` fixture sites past
1675 // the ★★ PRIME-DIRECTIVE ≥ 2 threshold collapse onto this
1676 // ONE substrate owner.
1677 let spec = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
1678 let mut a = EphemeralAllocation::new("obs-alloc", spec);
1679 a.status = Some(AllocationStatus {
1680 phase,
1681 ..AllocationStatus::default()
1682 });
1683 a
1684 }
1685
1686 fn alloc_without_status() -> EphemeralAllocation {
1687 // AllocationSpec rides through `AllocationSpec::requestor_only`
1688 // — sibling to `alloc_with_phase`.
1689 let spec = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
1690 let mut a = EphemeralAllocation::new("no-status-alloc", spec);
1691 a.status = None;
1692 a
1693 }
1694
1695 #[test]
1696 fn observed_phase_returns_none_when_status_is_none() {
1697 let a = alloc_without_status();
1698 assert!(a.observed_phase().is_none());
1699 }
1700
1701 #[test]
1702 fn observed_phase_returns_populated_variant_verbatim() {
1703 for p in AllocationPhase::ALL {
1704 let a = alloc_with_phase(p);
1705 assert_eq!(
1706 a.observed_phase(),
1707 Some(p),
1708 "observed_phase must project the persisted variant verbatim for {p:?}"
1709 );
1710 }
1711 }
1712
1713 #[test]
1714 fn observed_phase_matches_pre_lift_chain_bytewise() {
1715 // Sweep every corner: (status: None) plus every populated
1716 // (status: Some(phase)) variant. The pre-lift chain was
1717 // `alloc.status.as_ref().map(|s| s.phase)` — a 3-link chain
1718 // hand-authored inline at the observer. The primitive must
1719 // return the same `Option<AllocationPhase>` on every corner.
1720 let none_alloc = alloc_without_status();
1721 assert_eq!(
1722 none_alloc.observed_phase(),
1723 none_alloc.status.as_ref().map(|s| s.phase),
1724 );
1725 for p in AllocationPhase::ALL {
1726 let a = alloc_with_phase(p);
1727 assert_eq!(
1728 a.observed_phase(),
1729 a.status.as_ref().map(|s| s.phase),
1730 "primitive must be byte-identical to the pre-lift chain for {p:?}",
1731 );
1732 }
1733 }
1734
1735 #[test]
1736 fn observed_phase_or_pending_defaults_to_pending_when_status_absent() {
1737 let a = alloc_without_status();
1738 assert_eq!(a.observed_phase_or_pending(), AllocationPhase::Pending);
1739 }
1740
1741 #[test]
1742 fn observed_phase_or_pending_returns_populated_phase_verbatim() {
1743 for p in AllocationPhase::ALL {
1744 let a = alloc_with_phase(p);
1745 assert_eq!(
1746 a.observed_phase_or_pending(),
1747 p,
1748 "populated status must pass through verbatim for {p:?}"
1749 );
1750 }
1751 }
1752
1753 #[test]
1754 fn observed_phase_or_pending_defaults_agree_with_allocation_phase_default() {
1755 // The `Pending` sink is load-bearing as the "not yet observed"
1756 // default. `AllocationPhase::default()` returns `Pending`; the
1757 // primitive must return the same variant on the missing-status
1758 // corner. A future default-variant rename that flipped
1759 // `AllocationPhase::default` without flipping the primitive
1760 // (or vice versa) surfaces here as a divergent seed for the
1761 // routing ladder.
1762 let a = alloc_without_status();
1763 assert_eq!(a.observed_phase_or_pending(), AllocationPhase::default());
1764 }
1765
1766 #[test]
1767 fn observed_phase_or_pending_matches_pre_lift_chain_bytewise() {
1768 // The exact pre-lift 5-line chain in
1769 // `tatara-pool-reconciler::allocation_decide::
1770 // AllocationConvergenceCtx::observe` was:
1771 // let phase = alloc
1772 // .status
1773 // .as_ref()
1774 // .map(|s| s.phase)
1775 // .unwrap_or(AllocationPhase::Pending);
1776 // Sweep every corner: (status: None) plus every populated
1777 // status variant. The primitive must be byte-identical for
1778 // every corner so the observer's routing decision matches
1779 // bytewise post-lift.
1780 let none_alloc = alloc_without_status();
1781 assert_eq!(
1782 none_alloc.observed_phase_or_pending(),
1783 none_alloc
1784 .status
1785 .as_ref()
1786 .map(|s| s.phase)
1787 .unwrap_or(AllocationPhase::Pending),
1788 );
1789 for p in AllocationPhase::ALL {
1790 let a = alloc_with_phase(p);
1791 assert_eq!(
1792 a.observed_phase_or_pending(),
1793 a.status
1794 .as_ref()
1795 .map(|s| s.phase)
1796 .unwrap_or(AllocationPhase::Pending),
1797 "primitive must be byte-identical to the pre-lift 5-line chain for {p:?}",
1798 );
1799 }
1800 }
1801
1802 #[test]
1803 fn observed_phase_or_pending_composes_from_observed_phase() {
1804 // The composer sits on top of the borrow-form projection —
1805 // `observed_phase_or_pending() == observed_phase().unwrap_or
1806 // (Pending)`. Pinning the composition means a future
1807 // normalization step layered onto `observed_phase` (a
1808 // generation-filter, a staleness gate, a canonicalization
1809 // pass) reaches BOTH the raw-`Option` accessor and the
1810 // `Pending`-sinked composer through the SAME upstream body,
1811 // without needing a per-corner rewrite of the composer.
1812 let none_alloc = alloc_without_status();
1813 assert_eq!(
1814 none_alloc.observed_phase_or_pending(),
1815 none_alloc
1816 .observed_phase()
1817 .unwrap_or(AllocationPhase::Pending),
1818 );
1819 for p in AllocationPhase::ALL {
1820 let a = alloc_with_phase(p);
1821 assert_eq!(
1822 a.observed_phase_or_pending(),
1823 a.observed_phase().unwrap_or(AllocationPhase::Pending),
1824 "composer must ride on top of the borrow-form projection for {p:?}",
1825 );
1826 }
1827 }
1828
1829 #[test]
1830 fn observed_phase_is_a_pure_projection() {
1831 // Reading the phase twice must not mutate the allocation or
1832 // its status slot — pure projection semantics. Also witnesses
1833 // that the accessor doesn't clone / drop the inner `phase`
1834 // (the `Copy` scalar comes out identical on both reads).
1835 let a = alloc_with_phase(AllocationPhase::Bound);
1836 let one = a.observed_phase();
1837 let two = a.observed_phase();
1838 assert_eq!(one, two);
1839 assert!(a.status.is_some(), "projection must not consume the status");
1840 }
1841
1842 #[test]
1843 fn observed_phase_pending_missing_status_and_populated_pending_collapse_to_same_composer_output(
1844 ) {
1845 // A subtle correctness pin: the missing-`status` corner and
1846 // a populated-with-Pending status BOTH read as `Pending`
1847 // through the composer — the observer cannot distinguish the
1848 // two through this accessor. This matches the pre-lift 5-line
1849 // chain's semantics exactly (an operator patching
1850 // `status.phase: Pending` is indistinguishable from a
1851 // freshly-admitted allocation with no status stamped yet).
1852 // The borrow-form `observed_phase` accessor DOES distinguish
1853 // the two, so a caller that needs to tell them apart reaches
1854 // for the raw `Option`.
1855 let none_alloc = alloc_without_status();
1856 let pending_alloc = alloc_with_phase(AllocationPhase::Pending);
1857
1858 assert_eq!(
1859 none_alloc.observed_phase_or_pending(),
1860 pending_alloc.observed_phase_or_pending(),
1861 );
1862 assert_ne!(
1863 none_alloc.observed_phase(),
1864 pending_alloc.observed_phase(),
1865 "borrow-form accessor MUST distinguish missing-status from populated-Pending",
1866 );
1867 }
1868
1869 #[test]
1870 fn observed_phase_or_pending_missing_status_sink_agrees_with_process_peer_shape() {
1871 // Cross-CRD peer-axis coherence with
1872 // `Process::observed_phase_or_pending`. Both primitives walk
1873 // the identical `.status.as_ref().map(|s| s.phase).unwrap_or
1874 // (<Phase>::Pending)` chain differing ONLY in the `Phase`
1875 // type projected. On a missing-status observation, each
1876 // primitive must return its CRD's `Default`-equivalent
1877 // `Pending` variant — for `EphemeralAllocation` that's
1878 // `AllocationPhase::Pending`; for `Process` that's
1879 // `crate::phase::ProcessPhase::Pending`. This pin binds the
1880 // sink-parity structurally so a future rename of either
1881 // default variant surfaces here as a divergent seed for the
1882 // observer's routing / dispatch decision rather than as
1883 // silent drift between the two reconcilers.
1884 let no_status_alloc = alloc_without_status();
1885 assert_eq!(
1886 no_status_alloc.observed_phase_or_pending(),
1887 AllocationPhase::default(),
1888 );
1889 // Peer-axis invariant on the `Process` side — the primitive
1890 // that owns the same shape reads `ProcessPhase::Pending` on
1891 // the missing-status corner via its own inherent method. The
1892 // parity is coordinated at the `Default` seat: both CRDs'
1893 // phase types default to `Pending`, so a rename that broke
1894 // one without the other would fail one of these two
1895 // conjoined assertions.
1896 assert_eq!(AllocationPhase::default(), AllocationPhase::Pending,);
1897 assert_eq!(
1898 crate::phase::ProcessPhase::default(),
1899 crate::phase::ProcessPhase::Pending,
1900 );
1901 }
1902
1903 // ─── EphemeralAllocation::observed_bound_pool substrate pins ────
1904 //
1905 // The borrow-form status-projection primitive on the bound-pool
1906 // axis. Collapses the pre-lift hand-authored `.status.as_ref()
1907 // .and_then(|s| s.bound_pool.clone())` chain in
1908 // `tatara-pool-reconciler::allocation_decide::
1909 // AllocationConvergenceCtx::observe`'s `bound_pool` seed onto the
1910 // ONE substrate primitive. Cross-CRD peer to
1911 // `Process::observed_identity` on the (CRD × structured-record-
1912 // slot × borrow-form) axis pair — both primitives walk the
1913 // identical `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
1914 // shape. Each pin is fail-before-pass-after: `observed_bound_pool`
1915 // did not exist pre-lift, so any test invoking it fails to compile
1916 // pre-lift and passes post-lift.
1917
1918 fn sample_pool_ref(name: &str, ns: &str) -> AllocationRef {
1919 // Fixture ref rides through the ONE substrate composer
1920 // `AllocationRef::new` — the `impl Into<String>` signature
1921 // accepts the borrow-form `&str` slot pair verbatim without
1922 // a per-fixture `.to_string()` promotion.
1923 AllocationRef::new(name, ns)
1924 }
1925
1926 fn alloc_with_bound_pool(bound: Option<AllocationRef>) -> EphemeralAllocation {
1927 // AllocationSpec rides through `AllocationSpec::requestor_only`
1928 // — sibling to `alloc_with_phase`.
1929 let spec = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
1930 let mut a = EphemeralAllocation::new("bp-alloc", spec);
1931 a.status = Some(AllocationStatus {
1932 phase: AllocationPhase::Bound,
1933 bound_pool: bound,
1934 ..AllocationStatus::default()
1935 });
1936 a
1937 }
1938
1939 #[test]
1940 fn observed_bound_pool_returns_none_when_status_is_none() {
1941 // Missing-`status` corner pin: the primitive collapses the
1942 // no-status case to `None` so downstream `.is_some()` /
1943 // `if let Some(_)` / `.cloned().unwrap_or_else(...)` behave
1944 // identically on an `EphemeralAllocation` whose status field
1945 // is `None` and on one whose status carries an unpopulated
1946 // `bound_pool` slot. Matches the pre-lift `.and_then(...)`
1947 // chain's `None` byte-identically at the pool reconciler's
1948 // Release-composition seed.
1949 let a = alloc_without_status();
1950 assert!(a.observed_bound_pool().is_none());
1951 }
1952
1953 #[test]
1954 fn observed_bound_pool_returns_none_when_slot_is_none() {
1955 // Empty-slot-under-populated-status corner pin: the primitive
1956 // returns `None`, matching the missing-`status` corner byte-
1957 // identically. A regression that treated the two corners
1958 // differently would silently promote an internal representation
1959 // detail (whether the pool reconciler has ever written a
1960 // status subresource) into observable behavior at the
1961 // Release-composition branch of the allocation reconciler's
1962 // `decide` transition rule.
1963 let a = alloc_with_bound_pool(None);
1964 assert!(a.observed_bound_pool().is_none());
1965 }
1966
1967 #[test]
1968 fn observed_bound_pool_returns_borrow_when_slot_is_populated() {
1969 // Happy-path pin: with a populated `status.bound_pool` slot,
1970 // the primitive returns a borrowed `&AllocationRef` whose
1971 // (name, namespace) fields match the persisted record. A
1972 // regression that filtered / reshaped / canonicalized the
1973 // record would surface here rather than as silent skew at the
1974 // Release-composition seed's `.cloned()` materialization.
1975 let expected = sample_pool_ref("demo-pool", "pools");
1976 let a = alloc_with_bound_pool(Some(expected.clone()));
1977 let observed = a.observed_bound_pool().expect("populated slot");
1978 assert_eq!(observed, &expected);
1979 assert_eq!(observed.name, "demo-pool");
1980 assert_eq!(observed.namespace, "pools");
1981 }
1982
1983 #[test]
1984 fn observed_bound_pool_is_a_zero_copy_borrow_projection() {
1985 // Borrow-discipline pin: the returned reference points at the
1986 // persisted `AllocationRef` in place — NOT a fresh allocation
1987 // or a clone. A regression that switched the projection to an
1988 // owned `AllocationRef` (via `.clone()`) would defeat the
1989 // zero-copy contract the lift's primary strict-widening
1990 // delivers (the observer's Release-composition arm clones
1991 // once at the composition point where the
1992 // `AllocationConvergenceCtx` snapshot slot requires the owned
1993 // value). Peer to the sibling
1994 // `Process::observed_identity_is_a_zero_copy_borrow_projection`
1995 // pin on the `Process` CRD's `status.identity` slot.
1996 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1997 let observed = a.observed_bound_pool().expect("populated slot") as *const _;
1998 let persisted = a.status.as_ref().unwrap().bound_pool.as_ref().unwrap() as *const _;
1999 assert!(std::ptr::eq(observed, persisted));
2000 }
2001
2002 #[test]
2003 fn observed_bound_pool_is_a_pure_projection() {
2004 // Purity pin: calling the projection twice on the same
2005 // `EphemeralAllocation` returns byte-identical borrows (same
2006 // pointer). A regression that introduced state — a lazy-
2007 // cached reference, a normalization step that ran once and
2008 // cached — would surface here rather than as silent drift
2009 // between two dispatches within one reconcile pass.
2010 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
2011 let one = a.observed_bound_pool().expect("populated slot") as *const _;
2012 let two = a.observed_bound_pool().expect("populated slot") as *const _;
2013 assert!(std::ptr::eq(one, two));
2014 }
2015
2016 #[test]
2017 fn observed_bound_pool_matches_pre_lift_chain_bytewise() {
2018 // Byte-identical parity pin between the borrow-form primitive
2019 // here and the pre-lift `tatara-pool-reconciler`
2020 // `.status.as_ref().and_then(|s| s.bound_pool.clone())` chain.
2021 // Sweeps every corner every callsite plausibly encounters
2022 // (missing status, empty `bound_pool` slot, populated
2023 // `bound_pool` slot). A regression that inserted a
2024 // normalization step at the primitive the pre-lift chain does
2025 // NOT apply — or vice versa — surfaces here rather than as
2026 // silent drift between the pre-lift consumer site and the ONE
2027 // substrate owner it now routes through.
2028 fn pre_lift(a: &EphemeralAllocation) -> Option<AllocationRef> {
2029 a.status.as_ref().and_then(|s| s.bound_pool.clone())
2030 }
2031 // Missing status.
2032 let a = alloc_without_status();
2033 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
2034 // Populated status, empty `bound_pool` slot.
2035 let a = alloc_with_bound_pool(None);
2036 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
2037 // Populated status, populated `bound_pool` slot.
2038 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
2039 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
2040 }
2041
2042 #[test]
2043 fn observed_bound_pool_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
2044 // Cross-corner coherence pin: the missing-`status` corner and
2045 // the populated-empty-slot corner return `Option`s whose
2046 // `.is_none()` / `.is_some()` observations are IDENTICAL. A
2047 // regression that promoted the missing-`status` corner to a
2048 // typed error (via a signature change to `Result<_, _>`) — or
2049 // that widened the empty-slot corner to a synthetic
2050 // `Some(AllocationRef::default())` — would surface here rather
2051 // than as silent operator-facing divergence between a never-
2052 // status-written allocation and a bound-pool-cleared
2053 // allocation on the Release-composition branch.
2054 let a_no_status = alloc_without_status();
2055 let a_empty_slot = alloc_with_bound_pool(None);
2056 assert_eq!(
2057 a_no_status.observed_bound_pool().is_none(),
2058 a_empty_slot.observed_bound_pool().is_none(),
2059 );
2060 assert_eq!(
2061 a_no_status.observed_bound_pool().is_some(),
2062 a_empty_slot.observed_bound_pool().is_some(),
2063 );
2064 }
2065
2066 #[test]
2067 fn observed_bound_pool_shape_agrees_with_process_observed_identity_peer_axis() {
2068 // Cross-CRD peer-axis coherence pin binding the SAME
2069 // `.status.as_ref().and_then(|s| s.<slot>.as_ref())` shape
2070 // that both `EphemeralAllocation::observed_bound_pool` (this
2071 // primitive) and `Process::observed_identity` walk, differing
2072 // ONLY in the record projected. Structural test — both
2073 // signatures must resolve as `&Self -> Option<&Record>` fn
2074 // pointers, so a future rename or a signature drift that
2075 // (say) widened one side to `Option<Record>` or narrowed one
2076 // side to `Option<&str>` fails to compile here rather than
2077 // silently drifting the two reconcilers apart at their
2078 // respective observer seeds. The runtime side of the pin
2079 // sweeps the missing-status + empty-slot corners on the
2080 // `EphemeralAllocation` half; the `Process` half is exercised
2081 // by its own `crd.rs::tests::observed_identity_*` pin
2082 // family — this test binds only the peer-axis shape.
2083 let a_no_status = alloc_without_status();
2084 let a_empty_slot = alloc_with_bound_pool(None);
2085 assert!(a_no_status.observed_bound_pool().is_none());
2086 assert!(a_empty_slot.observed_bound_pool().is_none());
2087 // Structural peer-axis coherence: bind both signatures as fn
2088 // pointers at their peer resolution type so the compiler
2089 // refuses to build if either side's shape drifts. The `_`
2090 // let-bindings assert the target type inference.
2091 let _bound_pool_shape: fn(&EphemeralAllocation) -> Option<&AllocationRef> =
2092 EphemeralAllocation::observed_bound_pool;
2093 let _identity_shape: fn(&crate::prelude::Process) -> Option<&crate::identity::Identity> =
2094 crate::prelude::Process::observed_identity;
2095 }
2096
2097 // ─── EphemeralAllocation::observed_expires_at substrate pins ────
2098 //
2099 // The copy-form status-projection primitive on the TTL-expiry axis.
2100 // Collapses the pre-lift hand-authored `.status.as_ref().and_then(
2101 // |s| s.expires_at)` chain in `tatara-pool-reconciler::
2102 // allocation_decide::AllocationConvergenceCtx::observe`'s
2103 // `expires_at` seed onto the ONE substrate primitive. Same-CRD peer
2104 // to `observed_phase` on the (copy-form × status-slot) axis — both
2105 // primitives walk the identical `.status.as_ref().<map|and_then>(
2106 // |s| s.<Copy-field>)` shape. Each pin is fail-before-pass-after:
2107 // `observed_expires_at` did not exist pre-lift, so any test invoking
2108 // it fails to compile pre-lift and passes post-lift.
2109
2110 fn alloc_with_expires_at(expires_at: Option<DateTime<Utc>>) -> EphemeralAllocation {
2111 // AllocationSpec rides through `AllocationSpec::requestor_only`
2112 // — sibling to `alloc_with_phase`.
2113 let spec = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
2114 let mut a = EphemeralAllocation::new("exp-alloc", spec);
2115 a.status = Some(AllocationStatus {
2116 phase: AllocationPhase::Bound,
2117 expires_at,
2118 ..AllocationStatus::default()
2119 });
2120 a
2121 }
2122
2123 #[test]
2124 fn observed_expires_at_returns_none_when_status_is_none() {
2125 // Missing-`status` corner pin: the primitive collapses the
2126 // no-status case to `None` so downstream `.is_some()` / any
2127 // deadline comparison behaves identically on an
2128 // `EphemeralAllocation` whose status field is `None` and on
2129 // one whose status carries an unpopulated `expires_at` slot.
2130 // Matches the pre-lift `.and_then(...)` chain's `None` byte-
2131 // identically at the pool reconciler's Release-composition
2132 // TTL gate.
2133 let a = alloc_without_status();
2134 assert!(a.observed_expires_at().is_none());
2135 }
2136
2137 #[test]
2138 fn observed_expires_at_returns_none_when_slot_is_none() {
2139 // Empty-slot-under-populated-status corner pin: the primitive
2140 // returns `None`, matching the missing-`status` corner byte-
2141 // identically. A regression that treated the two corners
2142 // differently would silently promote an internal representation
2143 // detail (whether the pool reconciler has ever written a
2144 // `status.expires_at` field for a not-yet-Bound allocation)
2145 // into observable behavior at the Release-composition branch
2146 // of the allocation reconciler's `decide` transition rule.
2147 let a = alloc_with_expires_at(None);
2148 assert!(a.observed_expires_at().is_none());
2149 }
2150
2151 #[test]
2152 fn observed_expires_at_returns_populated_timestamp_verbatim() {
2153 // Happy-path pin: with a populated `status.expires_at` slot,
2154 // the primitive returns the persisted `DateTime<Utc>` verbatim.
2155 // A regression that filtered / clamped / canonicalized the
2156 // timestamp would surface here rather than as silent skew at
2157 // the Release-composition TTL gate's `>=` deadline comparison.
2158 let expected = Utc::now();
2159 let a = alloc_with_expires_at(Some(expected));
2160 assert_eq!(a.observed_expires_at(), Some(expected));
2161 }
2162
2163 #[test]
2164 fn observed_expires_at_is_a_pure_projection() {
2165 // Purity pin: calling the projection twice on the same
2166 // `EphemeralAllocation` returns byte-identical `Option`s. A
2167 // regression that introduced state — a lazy-cached value, a
2168 // normalization step that ran once and cached — would surface
2169 // here rather than as silent drift between two dispatches
2170 // within one reconcile pass.
2171 let expected = Utc::now();
2172 let a = alloc_with_expires_at(Some(expected));
2173 assert_eq!(a.observed_expires_at(), a.observed_expires_at());
2174 }
2175
2176 #[test]
2177 fn observed_expires_at_matches_pre_lift_chain_bytewise() {
2178 // Byte-identical parity pin between the copy-form primitive
2179 // here and the pre-lift `tatara-pool-reconciler`
2180 // `.status.as_ref().and_then(|s| s.expires_at)` chain. Sweeps
2181 // every corner every callsite plausibly encounters (missing
2182 // status, empty `expires_at` slot, populated `expires_at`
2183 // slot). A regression that inserted a normalization step at
2184 // the primitive the pre-lift chain does NOT apply — or vice
2185 // versa — surfaces here rather than as silent drift between
2186 // the pre-lift consumer site and the ONE substrate owner it
2187 // now routes through.
2188 fn pre_lift(a: &EphemeralAllocation) -> Option<DateTime<Utc>> {
2189 a.status.as_ref().and_then(|s| s.expires_at)
2190 }
2191 // Missing status.
2192 let a = alloc_without_status();
2193 assert_eq!(a.observed_expires_at(), pre_lift(&a));
2194 // Populated status, empty `expires_at` slot.
2195 let a = alloc_with_expires_at(None);
2196 assert_eq!(a.observed_expires_at(), pre_lift(&a));
2197 // Populated status, populated `expires_at` slot.
2198 let a = alloc_with_expires_at(Some(Utc::now()));
2199 assert_eq!(a.observed_expires_at(), pre_lift(&a));
2200 }
2201
2202 #[test]
2203 fn observed_expires_at_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
2204 // Cross-corner coherence pin: the missing-`status` corner and
2205 // the populated-empty-slot corner return `Option`s whose
2206 // `.is_none()` / `.is_some()` observations are IDENTICAL. A
2207 // regression that promoted the missing-`status` corner to a
2208 // typed error (via a signature change to `Result<_, _>`) — or
2209 // that widened the empty-slot corner to a synthetic
2210 // `Some(Utc::now())` — would surface here rather than as
2211 // silent operator-facing divergence between a never-status-
2212 // written allocation and a Bind-time-without-TTL allocation on
2213 // the Release-composition branch.
2214 let a_no_status = alloc_without_status();
2215 let a_empty_slot = alloc_with_expires_at(None);
2216 assert_eq!(
2217 a_no_status.observed_expires_at().is_none(),
2218 a_empty_slot.observed_expires_at().is_none(),
2219 );
2220 assert_eq!(
2221 a_no_status.observed_expires_at().is_some(),
2222 a_empty_slot.observed_expires_at().is_some(),
2223 );
2224 }
2225
2226 #[test]
2227 fn observed_expires_at_shape_agrees_with_observed_phase_peer_axis() {
2228 // Same-CRD peer-axis coherence pin binding the SAME
2229 // `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape
2230 // that both `EphemeralAllocation::observed_expires_at` (this
2231 // primitive) and `EphemeralAllocation::observed_phase` walk,
2232 // differing only in the outer combinator (`and_then` here
2233 // because the persisted field is itself `Option<T>`, `map`
2234 // there because the persisted phase is bare) and in the
2235 // projected `Copy` type. Structural test — both signatures
2236 // must resolve as `&Self -> Option<T>` fn pointers with `T`
2237 // `Copy`, so a future rename or a signature drift that (say)
2238 // widened one side to `Option<&T>` or narrowed one side to
2239 // `T` fails to compile here rather than silently drifting
2240 // the family apart. The runtime side of the pin sweeps the
2241 // missing-status + empty-slot corners on the `expires_at`
2242 // half; the `phase` half is exercised by its own
2243 // `tests::observed_phase_*` pin family — this test binds
2244 // only the peer-axis shape.
2245 let a_no_status = alloc_without_status();
2246 let a_empty_slot = alloc_with_expires_at(None);
2247 assert!(a_no_status.observed_expires_at().is_none());
2248 assert!(a_empty_slot.observed_expires_at().is_none());
2249 // Structural peer-axis coherence: bind both signatures as fn
2250 // pointers at their peer resolution type so the compiler
2251 // refuses to build if either side's shape drifts.
2252 let _expires_at_shape: fn(&EphemeralAllocation) -> Option<DateTime<Utc>> =
2253 EphemeralAllocation::observed_expires_at;
2254 let _phase_shape: fn(&EphemeralAllocation) -> Option<AllocationPhase> =
2255 EphemeralAllocation::observed_phase;
2256 }
2257
2258 #[test]
2259 fn allocation_spec_omits_optional_fields() {
2260 // AllocationSpec rides through `AllocationSpec::requestor_only`
2261 // + the inner Requestor through `Requestor::kind_only`; the
2262 // wire-shape pin still holds because BOTH composers produce
2263 // the byte-identical minimal shape whose `skip_serializing_if
2264 // = "Option::is_none"` + default-vec serde attributes elide
2265 // every optional slot from the YAML output.
2266 let s = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
2267 let yaml = serde_yaml::to_string(&s).unwrap();
2268 assert!(!yaml.contains("poolRef"));
2269 assert!(!yaml.contains("ttl"));
2270 assert!(!yaml.contains("note"));
2271 }
2272
2273 // ─── AllocationSpec::requestor_only substrate pins ──────────────
2274 //
2275 // The pre-lift `AllocationSpec { pool_ref: None, requestor: <r>,
2276 // ttl: None, note: None }` incantation recurred at NINE workspace-
2277 // wide fixture sites past the ★★ PRIME-DIRECTIVE ≥ 2 threshold
2278 // (five inside this file's own test module, three inside the
2279 // crate's `tests_owned_coordinates` / `tests_annotated` /
2280 // `tests_deletion_tombstoned` pin modules on `lib.rs`, and one in
2281 // `tatara-pool-reconciler::allocation_decide::alloc`). Every corner
2282 // of the three-slot default tail is pinned here so a future
2283 // normalization at the primitive lands with a fail-before-pass-
2284 // after regression at THIS composer's pins rather than as silent
2285 // fixture skew across the nine callsite arms.
2286
2287 #[test]
2288 fn requestor_only_leaves_the_three_slot_default_tail_at_the_substrate_owner() {
2289 // Every default-tail slot must land at the values the substrate
2290 // owner stamps: `pool_ref = None` (selector-based routing),
2291 // `ttl = None` (fall back to pool template TTL), `note = None`
2292 // (empty audit slot). A regression that drifted ANY of the three
2293 // defaults would silently reshape every downstream fixture
2294 // simultaneously; this pin catches it.
2295 let s = AllocationSpec::requestor_only(Requestor::kind_only("manual"));
2296 assert!(s.pool_ref.is_none(), "pool_ref must default to None");
2297 assert!(s.ttl.is_none(), "ttl must default to None");
2298 assert!(s.note.is_none(), "note must default to None");
2299 }
2300
2301 #[test]
2302 fn requestor_only_stamps_the_caller_requestor_verbatim() {
2303 // The single caller-varying slot MUST pass through untouched —
2304 // a regression that copied only a subset of the Requestor's
2305 // seven slots (e.g. re-authoring `Requestor { kind: r.kind, ..
2306 // Default::default() }` inside the composer) would drop the
2307 // caller's `repo` / `branch` / `pr_number` / `sha` / `pr_labels`
2308 // / `actor` at every downstream fixture. Passes a fully-
2309 // populated `Requestor` through and asserts every slot lands.
2310 let r = Requestor {
2311 kind: "github-pr".into(),
2312 repo: Some("pleme-io/demo".into()),
2313 branch: Some("main".into()),
2314 pr_number: Some(42),
2315 sha: Some("deadbeef".into()),
2316 pr_labels: vec!["needs-review".into()],
2317 actor: Some("dozer".into()),
2318 };
2319 let s = AllocationSpec::requestor_only(r.clone());
2320 assert_eq!(s.requestor.kind, r.kind);
2321 assert_eq!(s.requestor.repo, r.repo);
2322 assert_eq!(s.requestor.branch, r.branch);
2323 assert_eq!(s.requestor.pr_number, r.pr_number);
2324 assert_eq!(s.requestor.sha, r.sha);
2325 assert_eq!(s.requestor.pr_labels, r.pr_labels);
2326 assert_eq!(s.requestor.actor, r.actor);
2327 }
2328
2329 #[test]
2330 fn requestor_only_matches_hand_authored_pre_lift_bytewise() {
2331 // Byte-identical parity with the pre-lift 5-line struct-
2332 // literal every downstream fixture restated verbatim. Swept
2333 // across the two representative requestor shapes: the
2334 // kind-only `"manual"` fixture (the majority of the collapsed
2335 // callsites) and the fully-populated github-pr requestor (the
2336 // `tatara-pool-reconciler::allocation_decide::alloc` shape).
2337 // A regression that reshaped the composer's output would
2338 // diverge from the pre-lift literal HERE rather than at every
2339 // downstream fixture's downstream assertion.
2340 let sample_requestors = [
2341 Requestor::kind_only("manual"),
2342 Requestor::kind_only("github-pr"),
2343 Requestor {
2344 kind: "github-pr".into(),
2345 repo: Some("pleme-io/demo".into()),
2346 branch: Some("main".into()),
2347 pr_number: None,
2348 sha: None,
2349 pr_labels: vec![],
2350 actor: None,
2351 },
2352 ];
2353 for r in sample_requestors {
2354 let via_primitive = AllocationSpec::requestor_only(r.clone());
2355 let hand_authored = AllocationSpec {
2356 pool_ref: None,
2357 requestor: r.clone(),
2358 ttl: None,
2359 note: None,
2360 };
2361 // Sweep every slot rather than round-tripping through
2362 // serde, so a slot rename that keeps the same serde name
2363 // still surfaces as a defect at the primitive's slot-
2364 // level parity.
2365 assert!(via_primitive.pool_ref.is_none() && hand_authored.pool_ref.is_none());
2366 assert_eq!(via_primitive.requestor.kind, hand_authored.requestor.kind);
2367 assert_eq!(via_primitive.ttl, hand_authored.ttl);
2368 assert_eq!(via_primitive.note, hand_authored.note);
2369 }
2370 }
2371
2372 // ─── AllocationStatus::transition substrate pins ────────────────────
2373 //
2374 // Pin the substrate composer at fail-before-pass-after granularity:
2375 // the composer did not exist pre-lift, so any regression against
2376 // the four hand-authored sites in
2377 // `tatara-pool-reconciler::controller_allocation::reconcile_inner`
2378 // surfaces at these pins rather than as silent operator-visible
2379 // status-patch skew.
2380
2381 fn anchor_time() -> DateTime<Utc> {
2382 // A deterministic non-`Utc::now()` anchor so pins that read
2383 // back `phase_since` do not race the wall clock.
2384 DateTime::parse_from_rfc3339("2026-05-01T00:00:00Z")
2385 .unwrap()
2386 .with_timezone(&Utc)
2387 }
2388
2389 #[test]
2390 fn allocation_status_transition_stamps_supplied_phase_verbatim() {
2391 for phase in AllocationPhase::ALL {
2392 let s = AllocationStatus::transition(phase, "irrelevant", anchor_time());
2393 assert_eq!(s.phase, phase, "phase drifted for {phase:?}");
2394 }
2395 }
2396
2397 #[test]
2398 fn allocation_status_transition_stamps_supplied_message_verbatim() {
2399 let s = AllocationStatus::transition(
2400 AllocationPhase::Queued,
2401 "pool matched; no Free member available",
2402 anchor_time(),
2403 );
2404 assert_eq!(
2405 s.message.as_deref(),
2406 Some("pool matched; no Free member available"),
2407 );
2408 }
2409
2410 #[test]
2411 fn allocation_status_transition_sets_phase_since_to_supplied_now() {
2412 let anchor = anchor_time();
2413 let s = AllocationStatus::transition(AllocationPhase::Bound, "bound", anchor);
2414 assert_eq!(
2415 s.phase_since,
2416 Some(anchor),
2417 "phase_since must be the supplied `now`, not a fresh Utc::now()",
2418 );
2419 }
2420
2421 #[test]
2422 fn allocation_status_transition_defaults_every_optional_slot() {
2423 // The composer stamps only the three always-present slots
2424 // (`phase + phase_since + message`); every other slot on
2425 // `AllocationStatus` must land at its `Default`-equivalent
2426 // variant so a caller-branch that attaches an optional slot
2427 // via struct-update syntax does not silently inherit a
2428 // pre-populated non-`None`/non-empty value.
2429 let s = AllocationStatus::transition(AllocationPhase::Released, "released", anchor_time());
2430 assert!(s.bound_pool.is_none(), "bound_pool must default to None");
2431 assert!(
2432 s.assigned_process.is_none(),
2433 "assigned_process must default to None"
2434 );
2435 assert!(
2436 s.allocated_at.is_none(),
2437 "allocated_at must default to None"
2438 );
2439 assert!(s.expires_at.is_none(), "expires_at must default to None");
2440 assert!(
2441 s.conditions.is_empty(),
2442 "conditions must default to an empty Vec"
2443 );
2444 }
2445
2446 #[test]
2447 fn allocation_status_transition_accepts_owned_string_and_static_str() {
2448 // `impl Into<String>` matches every current callsite:
2449 // three of the four hand-authored sites pass `&'static str`
2450 // literal reasons; the fourth ("bound to pool member") also
2451 // passes a `&'static str`. Sibling to
2452 // `tatara-reconciler::patch::phase_status_msg`'s identical
2453 // `impl Into<String>` signature.
2454 let via_static = AllocationStatus::transition(
2455 AllocationPhase::NoMatchingPool,
2456 "no Pool selector matched this Requestor",
2457 anchor_time(),
2458 );
2459 let via_owned = AllocationStatus::transition(
2460 AllocationPhase::NoMatchingPool,
2461 String::from("no Pool selector matched this Requestor"),
2462 anchor_time(),
2463 );
2464 assert_eq!(via_static.message, via_owned.message);
2465 }
2466
2467 #[test]
2468 fn allocation_status_transition_serializes_to_pre_lift_json_shape() {
2469 // Byte-shape pin against the exact `json!({ "status": {
2470 // "phase": <variant>, "phaseSince": <now>, "message": "<msg>"
2471 // } })` incantation every pre-lift callsite restated. A
2472 // regression that reordered a slot, dropped the `phaseSince`
2473 // stamp, or drifted the camelCase key naming here surfaces at
2474 // THIS pin rather than as a subtle patch_status body the K8s
2475 // API server accepts but the pool reconciler's next observe
2476 // pass fails to read back.
2477 let anchor = anchor_time();
2478 let via_composer =
2479 AllocationStatus::transition(AllocationPhase::NoMatchingPool, "no match", anchor);
2480 let composed = serde_json::json!({ "status": via_composer });
2481 let hand_authored = serde_json::json!({
2482 "status": {
2483 "phase": AllocationPhase::NoMatchingPool,
2484 "phaseSince": anchor,
2485 "message": "no match",
2486 }
2487 });
2488 assert_eq!(composed, hand_authored);
2489 }
2490
2491 #[test]
2492 fn allocation_status_transition_composes_with_struct_update_for_bind_seed() {
2493 // Pin the compound shape the `AllocationDecision::Bind`
2494 // callsite composes: the substrate seed carries `phase +
2495 // phase_since + message`, and the branch attaches
2496 // `bound_pool` + `assigned_process` + `allocated_at` +
2497 // `expires_at` via struct-update syntax. Post-lift the four
2498 // extra slots survive the compose intact and the base three
2499 // slots inherit the composer's stamps verbatim.
2500 let anchor = anchor_time();
2501 let ttl = anchor + chrono::Duration::hours(1);
2502 let pool = AllocationRef::new("demo-pool", "pools");
2503 let assigned = AllocationRef::new("demo-abcd", "pools");
2504 let bind_status = AllocationStatus {
2505 bound_pool: Some(pool.clone()),
2506 assigned_process: Some(assigned.clone()),
2507 allocated_at: Some(anchor),
2508 expires_at: Some(ttl),
2509 ..AllocationStatus::transition(AllocationPhase::Bound, "bound to pool member", anchor)
2510 };
2511 // Base-three slots stamped by the composer.
2512 assert_eq!(bind_status.phase, AllocationPhase::Bound);
2513 assert_eq!(bind_status.phase_since, Some(anchor));
2514 assert_eq!(bind_status.message.as_deref(), Some("bound to pool member"));
2515 // Struct-update-attached branch slots.
2516 assert_eq!(
2517 bind_status.bound_pool.as_ref().map(|r| &r.name),
2518 Some(&pool.name)
2519 );
2520 assert_eq!(
2521 bind_status.assigned_process.as_ref().map(|r| &r.name),
2522 Some(&assigned.name)
2523 );
2524 assert_eq!(bind_status.allocated_at, Some(anchor));
2525 assert_eq!(bind_status.expires_at, Some(ttl));
2526 }
2527
2528 // ─── AllocationStatus::bound_transition substrate pins ─────────────
2529 //
2530 // Pin the compound composer at fail-before-pass-after granularity:
2531 // the composer wraps [`AllocationStatus::transition`] with the
2532 // `bound_pool + assigned_process` pair the Bind / Release arms
2533 // both stamped inline pre-lift.
2534
2535 #[test]
2536 fn allocation_status_bound_transition_stamps_supplied_pool_and_process_verbatim() {
2537 let anchor = anchor_time();
2538 let pool = AllocationRef::new("demo-pool", "pools");
2539 let assigned = AllocationRef::new("demo-abcd", "pools");
2540 let s = AllocationStatus::bound_transition(
2541 AllocationPhase::Released,
2542 "released; pool reconciler will return the member",
2543 anchor,
2544 pool.clone(),
2545 assigned.clone(),
2546 );
2547 assert_eq!(s.bound_pool.as_ref(), Some(&pool));
2548 assert_eq!(s.assigned_process.as_ref(), Some(&assigned));
2549 }
2550
2551 #[test]
2552 fn allocation_status_bound_transition_inherits_transition_triplet_verbatim() {
2553 // The compound composer must not stamp its own `phase +
2554 // phase_since + message` triplet — it MUST compose the pair
2555 // atop the substrate `Self::transition` seed so any future
2556 // evolution to the base triplet lands at ONE site and this
2557 // composer inherits the upgrade mechanically. Pin the triplet
2558 // through the same axis-uniform reads the transition tests use.
2559 let anchor = anchor_time();
2560 let via_compound = AllocationStatus::bound_transition(
2561 AllocationPhase::Bound,
2562 "bound to pool member",
2563 anchor,
2564 AllocationRef::new("p", "ns"),
2565 AllocationRef::new("q", "ns"),
2566 );
2567 let via_base =
2568 AllocationStatus::transition(AllocationPhase::Bound, "bound to pool member", anchor);
2569 assert_eq!(via_compound.phase, via_base.phase);
2570 assert_eq!(via_compound.phase_since, via_base.phase_since);
2571 assert_eq!(via_compound.message, via_base.message);
2572 }
2573
2574 #[test]
2575 fn allocation_status_bound_transition_defaults_every_optional_slot_beyond_the_pair() {
2576 // The compound composer stamps only the base triplet + the
2577 // `bound_pool + assigned_process` pair; every other optional
2578 // slot (`allocated_at` / `expires_at` / `conditions`) must
2579 // land at its `Default`-equivalent variant so a caller-branch
2580 // that attaches an addendum via struct-update syntax (a Bind
2581 // arm's `allocated_at` + `expires_at` stamp) does not
2582 // silently inherit a pre-populated non-`None`/non-empty value.
2583 let s = AllocationStatus::bound_transition(
2584 AllocationPhase::Released,
2585 "released",
2586 anchor_time(),
2587 AllocationRef::new("p", "ns"),
2588 AllocationRef::new("q", "ns"),
2589 );
2590 assert!(
2591 s.allocated_at.is_none(),
2592 "allocated_at must default to None"
2593 );
2594 assert!(s.expires_at.is_none(), "expires_at must default to None");
2595 assert!(
2596 s.conditions.is_empty(),
2597 "conditions must default to an empty Vec"
2598 );
2599 }
2600
2601 #[test]
2602 fn allocation_status_bound_transition_composes_with_struct_update_for_bind_seed() {
2603 // Pin the compound shape the `AllocationDecision::Bind`
2604 // callsite post-lift composes: the compound composer seeds
2605 // `phase + phase_since + message + bound_pool +
2606 // assigned_process`, and the Bind branch attaches
2607 // `allocated_at` + `expires_at` via struct-update syntax.
2608 // Post-lift the two extra slots survive the compose intact
2609 // and the base five slots inherit the composer's stamps
2610 // verbatim.
2611 let anchor = anchor_time();
2612 let ttl = anchor + chrono::Duration::hours(1);
2613 let pool = AllocationRef::new("demo-pool", "pools");
2614 let assigned = AllocationRef::new("demo-abcd", "pools");
2615 let bind_status = AllocationStatus {
2616 allocated_at: Some(anchor),
2617 expires_at: Some(ttl),
2618 ..AllocationStatus::bound_transition(
2619 AllocationPhase::Bound,
2620 "bound to pool member",
2621 anchor,
2622 pool.clone(),
2623 assigned.clone(),
2624 )
2625 };
2626 assert_eq!(bind_status.phase, AllocationPhase::Bound);
2627 assert_eq!(bind_status.phase_since, Some(anchor));
2628 assert_eq!(bind_status.message.as_deref(), Some("bound to pool member"));
2629 assert_eq!(bind_status.bound_pool.as_ref(), Some(&pool));
2630 assert_eq!(bind_status.assigned_process.as_ref(), Some(&assigned));
2631 assert_eq!(bind_status.allocated_at, Some(anchor));
2632 assert_eq!(bind_status.expires_at, Some(ttl));
2633 }
2634
2635 #[test]
2636 fn allocation_status_bound_transition_matches_pre_lift_release_arm_verbatim() {
2637 // Byte-shape pin against the exact pre-lift `AllocationStatus
2638 // { bound_pool: Some(pool), assigned_process:
2639 // Some(AllocationRef::new(..)), ..AllocationStatus::transition
2640 // (Released, "…", now) }` composition the
2641 // `AllocationDecision::Release` arm restated inline pre-lift.
2642 // A regression that reordered the pair, dropped a `Some`, or
2643 // drifted the composed base triplet here surfaces at THIS pin
2644 // rather than as a subtle patch_status body the K8s API
2645 // server accepts but the audit record disagrees on.
2646 let anchor = anchor_time();
2647 let pool = AllocationRef::new("demo-pool", "pools");
2648 let assigned = AllocationRef::new("demo-abcd", "pools");
2649 let via_composer = AllocationStatus::bound_transition(
2650 AllocationPhase::Released,
2651 "released; pool reconciler will return the member",
2652 anchor,
2653 pool.clone(),
2654 assigned.clone(),
2655 );
2656 let via_hand_authored = AllocationStatus {
2657 bound_pool: Some(pool),
2658 assigned_process: Some(assigned),
2659 ..AllocationStatus::transition(
2660 AllocationPhase::Released,
2661 "released; pool reconciler will return the member",
2662 anchor,
2663 )
2664 };
2665 assert_eq!(
2666 serde_json::to_value(&via_composer).unwrap(),
2667 serde_json::to_value(&via_hand_authored).unwrap(),
2668 );
2669 }
2670
2671 // ─── AllocationStatus::transition_now substrate pins ──────────────
2672 //
2673 // Bind [`AllocationStatus::transition_now`] at fail-before-pass-after
2674 // granularity so a regression that dropped the wall-clock read
2675 // (yielding a `phase_since` of `Some(DateTime::default())`),
2676 // reshaped the delegation target (a peer 3-arg composer that
2677 // stamped different defaults), or diverged the peer from the 3-arg
2678 // [`AllocationStatus::transition`] on any observable slot surfaces
2679 // HERE rather than as silent operator-facing drift at the two
2680 // controller_allocation status-patch sites.
2681 //
2682 // Each pin is fail-before-pass-after: the primitive did not exist
2683 // pre-lift, so any test that invokes it fails to compile pre-lift
2684 // and passes post-lift; the byte-identity pins below then bind the
2685 // specific shape choice. Sibling of the
2686 // `pool_status_observed_now_*` family in `crate::pool`.
2687
2688 #[test]
2689 fn allocation_status_transition_now_composes_through_transition_with_wall_clock() {
2690 // Composition pin: `transition_now` MUST agree with the 3-arg
2691 // `transition(phase, msg, Utc::now())` peer at every slot other
2692 // than `phase_since` (which reads the wall clock at different
2693 // instants and diverges by scheduler jitter). A regression that
2694 // specialized either composer (a stray canonicalization at
2695 // `transition_now`, a swapped default at the 3-arg peer) would
2696 // surface HERE rather than as silent skew at the two
2697 // controller_allocation sites the primitive owns.
2698 let via_now = AllocationStatus::transition_now(AllocationPhase::Queued, "queued");
2699 let via_injected =
2700 AllocationStatus::transition(AllocationPhase::Queued, "queued", Utc::now());
2701 assert_eq!(via_now.phase, via_injected.phase);
2702 assert_eq!(via_now.message, via_injected.message);
2703 assert_eq!(via_now.bound_pool, via_injected.bound_pool);
2704 assert_eq!(via_now.assigned_process, via_injected.assigned_process);
2705 assert_eq!(via_now.allocated_at, via_injected.allocated_at);
2706 assert_eq!(via_now.expires_at, via_injected.expires_at);
2707 assert_eq!(via_now.conditions.len(), via_injected.conditions.len());
2708 }
2709
2710 #[test]
2711 fn allocation_status_transition_now_reads_wall_clock_into_phase_since() {
2712 // Wall-clock pin: `phase_since` MUST fall between `Utc::now()`
2713 // reads bracketed around the call. A regression that dropped
2714 // the wall-clock read to a module-load constant (`Utc::now()`
2715 // captured at `static` init), a `DateTime::default()` (epoch),
2716 // or a stale `None` would fail this bracket check.
2717 let before = Utc::now();
2718 let s = AllocationStatus::transition_now(
2719 AllocationPhase::NoMatchingPool,
2720 "no Pool selector matched this Requestor",
2721 );
2722 let after = Utc::now();
2723 let phase_since = s
2724 .phase_since
2725 .expect("transition_now must stamp phase_since with the wall clock");
2726 assert!(
2727 phase_since >= before && phase_since <= after,
2728 "phase_since {phase_since} must fall in [{before}, {after}]"
2729 );
2730 }
2731
2732 #[test]
2733 fn allocation_status_transition_now_stamps_the_same_defaults_as_the_injected_peer() {
2734 // Defaults pin: every optional slot beyond the base triplet
2735 // (`bound_pool` / `assigned_process` / `allocated_at` /
2736 // `expires_at` / `conditions`) MUST agree with the 3-arg
2737 // [`AllocationStatus::transition`] peer verbatim. A regression
2738 // that stamped a per-caller default at `transition_now` (a
2739 // "wall-clock-stamped transition" placeholder, say) or seeded
2740 // a "just-transitioned" Condition row would surface HERE
2741 // rather than as silent operator-facing drift at either
2742 // status-patch site.
2743 let s = AllocationStatus::transition_now(AllocationPhase::NoMatchingPool, "no match");
2744 assert!(s.bound_pool.is_none(), "bound_pool must default to None");
2745 assert!(
2746 s.assigned_process.is_none(),
2747 "assigned_process must default to None"
2748 );
2749 assert!(
2750 s.allocated_at.is_none(),
2751 "allocated_at must default to None"
2752 );
2753 assert!(s.expires_at.is_none(), "expires_at must default to None");
2754 assert!(
2755 s.conditions.is_empty(),
2756 "conditions must default to an empty Vec"
2757 );
2758 }
2759
2760 #[test]
2761 fn allocation_status_transition_now_wall_clock_is_read_per_invocation_not_cached() {
2762 // Monotonic-read pin: two back-to-back `transition_now` calls
2763 // MUST read `Utc::now()` twice — the second `phase_since` MUST
2764 // be `>=` the first. A regression that cached a wall-clock
2765 // read into a `OnceLock` / lazy `static` would fire the SAME
2766 // `phase_since` for every caller on the reconciler's process
2767 // and every status-patch would carry the module-load instant
2768 // rather than the tick instant. Both instants may coincide on
2769 // a fast machine; use `>=` (not `>`) to keep the pin robust
2770 // against subsecond scheduler granularity while still catching
2771 // a cached-constant regression (where the second read would
2772 // be < the wall clock).
2773 let first = AllocationStatus::transition_now(AllocationPhase::Queued, "queued")
2774 .phase_since
2775 .expect("first transition_now stamps phase_since");
2776 let second = AllocationStatus::transition_now(AllocationPhase::Queued, "queued")
2777 .phase_since
2778 .expect("second transition_now stamps phase_since");
2779 assert!(
2780 second >= first,
2781 "second phase_since {second} must be >= first phase_since {first}"
2782 );
2783 let after = Utc::now();
2784 assert!(
2785 second <= after,
2786 "second phase_since {second} must be <= {after}"
2787 );
2788 }
2789
2790 #[test]
2791 fn allocation_status_transition_now_accepts_owned_string_and_static_str() {
2792 // `impl Into<String>` matches every current callsite: both
2793 // hand-authored production sites pass `&'static str` literal
2794 // reasons; a future callsite that composes a `format!`-owned
2795 // reason routes through the same signature without widening.
2796 // Sibling to the 3-arg `AllocationStatus::transition` peer's
2797 // identical `impl Into<String>` signature.
2798 let via_static = AllocationStatus::transition_now(
2799 AllocationPhase::NoMatchingPool,
2800 "no Pool selector matched this Requestor",
2801 );
2802 let via_owned = AllocationStatus::transition_now(
2803 AllocationPhase::NoMatchingPool,
2804 String::from("no Pool selector matched this Requestor"),
2805 );
2806 assert_eq!(via_static.message, via_owned.message);
2807 }
2808
2809 #[test]
2810 fn allocation_status_transition_now_composes_with_struct_update_for_wait_seed() {
2811 // Pin the compound shape the `AllocationDecision::Wait { pool }`
2812 // callsite post-lift composes: the composer seeds `phase +
2813 // phase_since + message`, and the Wait branch attaches
2814 // `bound_pool: Some(pool)` via struct-update syntax. Post-lift
2815 // the branch slot survives the compose intact and the base
2816 // three slots inherit the composer's stamps verbatim — matches
2817 // the pre-lift shape where `..AllocationStatus::transition(
2818 // Queued, msg, Utc::now())` fed the same struct-update seed.
2819 let pool = AllocationRef::new("attest-pool", "pools");
2820 let wait_status = AllocationStatus {
2821 bound_pool: Some(pool.clone()),
2822 ..AllocationStatus::transition_now(
2823 AllocationPhase::Queued,
2824 "pool matched; no Free member available",
2825 )
2826 };
2827 assert_eq!(wait_status.phase, AllocationPhase::Queued);
2828 assert_eq!(wait_status.bound_pool.as_ref(), Some(&pool));
2829 assert_eq!(
2830 wait_status.message.as_deref(),
2831 Some("pool matched; no Free member available")
2832 );
2833 assert!(
2834 wait_status.phase_since.is_some(),
2835 "phase_since must be stamped from the wall clock"
2836 );
2837 assert!(
2838 wait_status.assigned_process.is_none(),
2839 "assigned_process must remain None on the Wait seed"
2840 );
2841 }
2842
2843 #[test]
2844 fn allocation_status_transition_now_shape_agrees_with_pool_status_observed_now_peer() {
2845 // Cross-CRD peer-axis coherence: both wall-clock-anchored
2846 // substrate composers
2847 // (`PoolStatus::observed_now`, `AllocationStatus::transition_now`)
2848 // read `Utc::now()` at their own body and stamp it into
2849 // `phase_since` uniformly across the two `<CRD>Status` axes.
2850 // Structural pin: if either side's `now` slot leaks into the
2851 // signature (e.g. adding an `impl Into<DateTime<Utc>>`
2852 // parameter), this bind fails to compile here rather than
2853 // silently drifting the wall-clock-anchored peer family apart.
2854 let _allocation_shape: fn(AllocationPhase, &'static str) -> AllocationStatus =
2855 AllocationStatus::transition_now;
2856 // (`PoolStatus::observed_now`'s pinned coherence lives at its
2857 // own peer pin family in `crate::pool`; this pin binds the
2858 // `AllocationStatus::transition_now` side of the peer pair.)
2859 }
2860
2861 #[test]
2862 fn allocation_status_transition_shape_agrees_with_pool_status_observed_peer() {
2863 // Cross-CRD peer-axis coherence: both substrate composers
2864 // (`PoolStatus::observed`, `AllocationStatus::transition`)
2865 // accept a caller-supplied `now: DateTime<Utc>` at the SAME
2866 // signature slot, stamp it into `phase_since` uniformly, and
2867 // leave every other slot at its `Default`-equivalent variant.
2868 // Structural pin: if either side's `now` signature drifts to
2869 // `impl Into<DateTime<Utc>>` or a reference form, this bind
2870 // fails to compile here rather than silently drifting the
2871 // family apart.
2872 let _allocation_shape: fn(
2873 AllocationPhase,
2874 &'static str,
2875 DateTime<Utc>,
2876 ) -> AllocationStatus = AllocationStatus::transition;
2877 // (`PoolStatus::observed`'s pinned coherence lives at its own
2878 // peer pin family in `crate::pool`; this pin binds the
2879 // `AllocationStatus::transition` side of the peer pair.)
2880 }
2881
2882 // ─── EphemeralAllocation::new_in substrate pins ────────────────────
2883 //
2884 // The pre-lift 2-line `let mut a = EphemeralAllocation::new(<name>,
2885 // <spec>); a.meta_mut().namespace = Some(<ns>.into());` chain
2886 // recurred at TWO workspace-wide sites past the ★★ PRIME-DIRECTIVE
2887 // ≥ 2 threshold across TWO crates (production PR-webhook emitter
2888 // at `tatara-github-watcher::allocation_factory::build_allocation`
2889 // + allocation-decision test fixture at `tatara-pool-reconciler::
2890 // allocation_decide::tests::alloc`). Post-lift the ONE substrate
2891 // composer stamps a namespaced `EphemeralAllocation` from
2892 // `(name, ns, spec)` in one call. Fail-before-pass-after
2893 // granularity: `new_in` did not exist pre-lift; the compiler
2894 // cannot resolve the name until the impl block above is in place,
2895 // so a rollback of the primitive breaks this whole pin block.
2896
2897 fn sample_spec() -> AllocationSpec {
2898 AllocationSpec::requestor_only(Requestor::kind_only("manual"))
2899 }
2900
2901 #[test]
2902 fn allocation_new_in_stamps_metadata_name_from_the_name_slot() {
2903 // `name` slot → `metadata.name` projection pin. Guards against
2904 // a regression that dropped the `name` slot into a `generate_
2905 // name` slot, an `annotations` seed, or any downstream slot the
2906 // kube-derived [`EphemeralAllocation::new`] does not populate
2907 // at `metadata.name` verbatim.
2908 let a = EphemeralAllocation::new_in("pr-42-demo", "pools", sample_spec());
2909 assert_eq!(a.metadata.name.as_deref(), Some("pr-42-demo"));
2910 }
2911
2912 #[test]
2913 fn allocation_new_in_stamps_metadata_namespace_from_the_ns_slot() {
2914 // `ns` slot → `metadata.namespace` projection pin. Guards
2915 // against a regression that dropped the `ns` slot into a
2916 // `labels` seed, an unrelated annotation, or that stamped
2917 // `namespace = None` even after a caller-supplied value.
2918 let a = EphemeralAllocation::new_in("pr-42-demo", "pools", sample_spec());
2919 assert_eq!(a.metadata.namespace.as_deref(), Some("pools"));
2920 }
2921
2922 #[test]
2923 fn allocation_new_in_stamps_spec_from_the_spec_slot_verbatim() {
2924 // `spec` slot → `spec` projection pin. A regression that
2925 // silently normalized the caller-supplied spec inside the
2926 // composer would diverge from the byte-identical pass-through
2927 // the pre-lift 2-line chain produced.
2928 let spec = AllocationSpec::requestor_only(Requestor::kind_only("github-pr"));
2929 let a = EphemeralAllocation::new_in("pr-42-demo", "pools", spec.clone());
2930 assert_eq!(a.spec.requestor.kind, spec.requestor.kind);
2931 assert!(a.spec.pool_ref.is_none());
2932 assert!(a.spec.ttl.is_none());
2933 assert!(a.spec.note.is_none());
2934 }
2935
2936 #[test]
2937 fn allocation_new_in_accepts_both_owned_and_borrowed_namespace_slot() {
2938 // The `impl Into<String>` ergonomic contract round-trips
2939 // through both `&'static str` (the pre-lift test-fixture caller
2940 // shape) AND owned `String` (the pre-lift production-emitter
2941 // caller shape at `build_allocation`, where `namespace:
2942 // &str` was pushed through a `.to_string()`) at the SAME
2943 // signature. Guards against a regression that narrowed the
2944 // slot to `&str` only or that silently double-`.into()`d an
2945 // already-owned String.
2946 let via_str = EphemeralAllocation::new_in("pr-42-demo", "pools", sample_spec());
2947 let via_string =
2948 EphemeralAllocation::new_in("pr-42-demo", String::from("pools"), sample_spec());
2949 assert_eq!(via_str.metadata.namespace, via_string.metadata.namespace);
2950 }
2951
2952 #[test]
2953 fn allocation_new_in_matches_pre_lift_construct_then_set_namespace_bytewise() {
2954 // Byte-shape parity witness against the pre-lift 2-line chain
2955 // across the two representative namespace shapes the collapsed
2956 // sites used (`"pools"` at `allocation_decide::alloc` +
2957 // production-emitter `"ephemeral-pools"` at `build_allocation`
2958 // + the free-form namespace `build_allocation` accepts). A
2959 // regression that shifted the composer's output would diverge
2960 // from the pre-lift literal HERE rather than at every
2961 // downstream consumer's assertion.
2962 for ns in ["pools", "ephemeral-pools", "custom-ns"] {
2963 let via_primitive = EphemeralAllocation::new_in("pr-42-demo", ns, sample_spec());
2964 let mut hand_authored = EphemeralAllocation::new("pr-42-demo", sample_spec());
2965 hand_authored.metadata.namespace = Some(ns.into());
2966 assert_eq!(via_primitive.metadata.name, hand_authored.metadata.name);
2967 assert_eq!(
2968 via_primitive.metadata.namespace,
2969 hand_authored.metadata.namespace,
2970 );
2971 }
2972 }
2973
2974 #[test]
2975 fn allocation_new_in_defaults_other_metadata_slots_at_kube_derived_new() {
2976 // The composer forwards to the kube-derived
2977 // [`EphemeralAllocation::new`] for every non-namespace metadata
2978 // slot. A regression that stamped finalizers, owner_references,
2979 // labels, or annotations inside the composer's body —
2980 // inheriting the pre-lift chain's undocumented emptiness at
2981 // those slots — would surface here.
2982 let a = EphemeralAllocation::new_in("pr-42-demo", "pools", sample_spec());
2983 assert!(a.metadata.finalizers.is_none());
2984 assert!(a.metadata.owner_references.is_none());
2985 assert!(a.metadata.labels.is_none());
2986 assert!(a.metadata.annotations.is_none());
2987 }
2988
2989 #[test]
2990 fn allocation_new_in_shape_agrees_with_pool_new_in_peer() {
2991 // Cross-CRD peer-axis structural coherence: both substrate
2992 // composers (`EphemeralPool::new_in`, `EphemeralAllocation::
2993 // new_in`) accept `(name: &str, namespace: impl Into<String>,
2994 // spec: <CRD>Spec)` at the SAME positional slot order and
2995 // return the CRD with `metadata.name` + `metadata.namespace`
2996 // stamped uniformly. Structural pin: if either side's slot
2997 // order drifts (e.g. `(ns, name, spec)`), this bind fails to
2998 // compile here rather than silently drifting the family apart.
2999 let _allocation_shape: fn(&str, &'static str, AllocationSpec) -> EphemeralAllocation =
3000 EphemeralAllocation::new_in;
3001 // (`EphemeralPool::new_in`'s pinned coherence lives at its own
3002 // peer pin family in `crate::pool`; this pin binds the
3003 // `EphemeralAllocation::new_in` side of the peer pair.)
3004 }
3005}