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
82/// Identity + routing context for a request.
83#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
84#[serde(rename_all = "camelCase")]
85pub struct Requestor {
86 /// Discriminator: `"github-pr"`, `"manual"`, `"ci-run"`,
87 /// `"scheduled"`, … The wire shape is open by design — operators
88 /// may register their own kinds and the [`crate::pool::PoolSelector`]
89 /// matches on raw string equality. The substrate's own emitters
90 /// stamp one of the four canonical kebab-case kinds enumerated by
91 /// [`RequestorKind::ALL`]; [`Requestor::known_kind`] projects the
92 /// open wire field through that closed-set view at ONE site so
93 /// future kind-keyed consumers (pool dashboards, completion lists,
94 /// audit-trail classifiers) sweep the typed variants without
95 /// re-implementing `match self.kind.as_str()` arm-by-arm. Sibling
96 /// shape to [`crate::receipt::ReceiptEnvelope::known_kind`].
97 pub kind: String,
98
99 /// Optional repo identifier (e.g., `"pleme-io/demo-app"`).
100 /// Matched against `PoolSelector.repos`.
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub repo: Option<String>,
103
104 /// Optional branch name. Matched against `PoolSelector.branches`.
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub branch: Option<String>,
107
108 /// Optional PR number (for `kind: github-pr`). Surfaces in
109 /// printcolumns + audit.
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub pr_number: Option<u64>,
112
113 /// Optional commit SHA (for `kind: github-pr` or `ci-run`).
114 /// Stamped onto the allocated Process for traceability.
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub sha: Option<String>,
117
118 /// PR / commit labels — matched as a subset against
119 /// `PoolSelector.prLabels`.
120 #[serde(default)]
121 pub pr_labels: Vec<String>,
122
123 /// Free-form actor — username, CI runner ID, etc.
124 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub actor: Option<String>,
126}
127
128impl Requestor {
129 /// Decode [`Self::kind`] into the typed [`RequestorKind`] variant
130 /// when the wire string matches one of the four substrate-emitted
131 /// canonical kebab-case kinds; `None` when the kind is an
132 /// operator-registered open string (the schema is open by design —
133 /// every allocation remains a valid allocation, but only typed
134 /// kinds participate in closed-set dispatch). The (open `String`,
135 /// closed-typed view) split lets future kind-keyed consumers
136 /// (pool-selector classifiers, dashboard completion, audit-trail
137 /// classifiers) sweep the typed variants without touching the
138 /// open-by-design wire shape. Lifted as the canonical decode site
139 /// so no consumer re-implements the `match self.kind.as_str()` arm-
140 /// by-arm — the closed-set sweep happens through
141 /// [`RequestorKind::from_str`] at ONE site. Sibling shape to
142 /// [`crate::receipt::ReceiptEnvelope::known_kind`].
143 #[must_use]
144 pub fn known_kind(&self) -> Option<RequestorKind> {
145 self.kind.parse().ok()
146 }
147}
148
149/// Closed-set view over the substrate-emitted canonical
150/// [`Requestor::kind`] wire strings — the four kebab-case
151/// discriminators every pleme-io requestor stamps onto an
152/// [`EphemeralAllocation`]: `github-pr` (the [`tatara_github_watcher`-
153/// authored](../../tatara-github-watcher/src/allocation_factory.rs)
154/// PR-driven path), `manual` (operator-authored via `feira allocation
155/// request …`), `ci-run` (non-PR CI driver), and `scheduled` (a
156/// cron-style emitter). The wire field stays `pub kind: String` on
157/// [`Requestor`] so operators can register their own kinds without a
158/// schema bump; this enum is the typed view future kind-keyed
159/// consumers (pool dashboards, LSP completion, audit-trail
160/// classifiers) sweep against.
161///
162/// Pre-lift the four canonical kinds existed only as `&'static str`
163/// literals at four scattered sites — the documentation header on
164/// [`Requestor::kind`], the [`crate::pool::PoolSelector::kinds`]
165/// docstring, the `tatara-github-watcher` allocation factory, and the
166/// per-test `kind: "github-pr".into()` fixtures. A rename of one
167/// canonical kind (e.g. `"github-pr"` → `"github-pull-request"`) had
168/// no compile-time link to the others, so the documentation drifted
169/// independently of the emitter, and the [`PoolSelector::matches`]
170/// kind-filter silently kept matching the old spelling forever. Post-
171/// lift the (canonical-name, typed-variant) pairing binds at ONE site
172/// ([`Self::as_str`]); the `From<RequestorKind> for String` bridge
173/// lets emitters compose `Requestor { kind: RequestorKind::GithubPr.into(), … }`
174/// so the four canonical strings stop appearing as bare `&'static str`
175/// literals at author sites.
176///
177/// Adding a fifth kind (e.g. `Slack` → `"slack"`, `Webhook` →
178/// `"webhook"`) lands at one [`Self::ALL`] entry + one [`Self::as_str`]
179/// arm — exhaustively checked by the compiler (the `[Self; 4]` array
180/// literal forces the arity) AND by the per-variant truth-table tests
181/// below.
182///
183/// Sibling closed-set `ALL`-keyed lifts across the crate:
184/// [`crate::receipt::ReceiptKind::ALL`] (the four substrate-emitted
185/// receipt kinds — direct shape peer, same open-wire + closed-view
186/// split), [`AllocationPhase::ALL`], [`crate::phase::ProcessPhase::ALL`],
187/// [`crate::signal::ProcessSignal::ALL`],
188/// [`crate::boundary::ConditionKind::ALL`],
189/// [`crate::lifetime::TeardownPolicy::ALL`],
190/// [`crate::lifetime::LifetimeKind::ALL`],
191/// [`crate::intent::IntentKind::ALL`],
192/// [`crate::lifetime_clock::TerminateReasonKind::ALL`].
193///
194/// Theory anchor: THEORY.md §III — the typescape; the substrate's own
195/// requestor kinds become a TYPE rather than four `&'static str`
196/// literals at every author + docstring + fixture site. THEORY.md
197/// §V.1 — knowable platform; the closed-set view turns "which kinds
198/// does the substrate actually emit" from a grep job into a method
199/// the compiler enforces exhaustively at every dispatch site.
200#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
201#[closed_set(via = "as_str", generate_unknown, display)]
202pub enum RequestorKind {
203 /// GitHub pull-request webhook — `tatara-github-watcher` stamps
204 /// this on every allocation built from a `PullRequestEvent`.
205 GithubPr,
206 /// Operator-authored allocation — `feira allocation request …`
207 /// and any hand-crafted CR.
208 Manual,
209 /// Non-PR CI driver — a pipeline run that wants an ephemeral env
210 /// without an associated pull request.
211 CiRun,
212 /// Cron-style scheduled emitter — periodic allocation creation
213 /// (e.g. nightly drift detection).
214 Scheduled,
215}
216
217impl RequestorKind {
218 /// The closed set of substrate-emitted requestor kinds — single
219 /// source of truth that drives the [`Self::from_str`] decode sweep
220 /// AND any future enumeration consumer (pool-selector classifiers,
221 /// dashboard completion, `tatara-check` kind enumeration). Adding
222 /// a fifth variant (e.g. `Slack` → `"slack"`) lands at one `ALL`
223 /// entry + one `as_str` arm — exhaustively checked by the compiler
224 /// (the `[Self; 4]` array literal forces the arity) AND by the
225 /// per-variant truth-table tests below.
226 pub const ALL: [Self; 4] = [Self::GithubPr, Self::Manual, Self::CiRun, Self::Scheduled];
227
228 /// Canonical kebab-case wire-format kind — the literal that lands
229 /// in [`Requestor::kind`] when this variant authors the request.
230 /// Pinned to four byte-exact strings the substrate has already
231 /// published (the `tatara-github-watcher` factory, the operator
232 /// fixtures in this file, the `PoolSelector.kinds` filter, the
233 /// CRD printcolumns) — renaming any one is a wire-format change,
234 /// not a typed-internal refactor, and the
235 /// `requestor_kind_canonical_names_pinned` truth-table test fails
236 /// first to keep the substrate honest. Used by [`std::fmt::Display`]
237 /// (single source of truth) and as the `String` projection that
238 /// `From<RequestorKind> for String` ([`Self::into`]) composes so
239 /// emitters can spell `Requestor { kind: RequestorKind::GithubPr.into(), … }`
240 /// without re-typing the canonical literal at every author site.
241 #[must_use]
242 pub const fn as_str(self) -> &'static str {
243 match self {
244 Self::GithubPr => "github-pr",
245 Self::Manual => "manual",
246 Self::CiRun => "ci-run",
247 Self::Scheduled => "scheduled",
248 }
249 }
250}
251
252// `impl FromStr for RequestorKind` + `impl tatara_lisp::ClosedSet for
253// RequestorKind` + `impl std::fmt::Display for RequestorKind` are
254// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
255// declaration above. `label` delegates to the inherent
256// `RequestorKind::as_str` via `#[closed_set(via = "as_str")]` so the
257// kebab-case wire-format projection stays load-bearing (matches the
258// `tatara-github-watcher` factory + the CRD printcolumns + the
259// `PoolSelector.kinds` filter verbatim) while generic `T: ClosedSet`
260// consumers reach the STABLE workspace-wide name (`label`). The
261// `display` flag emits the `f.write_str(self.as_str())` delegation
262// block — the substrate-wide closed-set-enum idiom's third piece —
263// at the same proc-macro site rather than a hand-rolled
264// `fmt::Display` block per implementor.
265
266// `pub struct UnknownRequestorKind(pub String)` is generated by
267// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
268// on the enum declaration above. The auto-derived label `"requestor kind"`
269// matches the prior hand-rolled `#[error("unknown requestor kind: {0}")]`
270// verbatim — pinned generically by clause (5) of
271// `tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>()` (called
272// from `requestor_kind_is_well_formed_closed_set` in the test module).
273// Symmetric to every sibling `Unknown*` error in this crate (e.g.
274// [`UnknownAllocationPhase`], [`crate::receipt::UnknownReceiptKind`],
275// [`crate::phase::UnknownPhase`], [`crate::lifetime::UnknownTeardownPolicy`]).
276
277impl From<RequestorKind> for String {
278 /// Composes [`RequestorKind::as_str`] into an owned `String` so
279 /// every `impl Into<String>` API surface (the `kind:` field
280 /// initializer on [`Requestor`] most notably) accepts the typed
281 /// variant transparently — the call site stays
282 /// `kind: RequestorKind::GithubPr.into()` and the typed → wire
283 /// bridge runs through ONE place. Sibling shape to
284 /// [`crate::receipt::ReceiptKind`]'s `From for String`.
285 fn from(k: RequestorKind) -> Self {
286 k.as_str().to_owned()
287 }
288}
289
290impl From<RequestorKind> for &'static str {
291 fn from(k: RequestorKind) -> Self {
292 k.as_str()
293 }
294}
295
296/// `EphemeralAllocation.status` — observed allocation state.
297#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
298#[serde(rename_all = "camelCase")]
299pub struct AllocationStatus {
300 /// Current lifecycle phase.
301 #[serde(default)]
302 pub phase: AllocationPhase,
303
304 /// When the phase last changed.
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub phase_since: Option<DateTime<Utc>>,
307
308 /// Pool that owns the matched member. Set as soon as routing
309 /// resolves; not cleared on release (audit trail).
310 #[serde(default, skip_serializing_if = "Option::is_none")]
311 pub bound_pool: Option<AllocationRef>,
312
313 /// The Process backing this allocation, if Bound.
314 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub assigned_process: Option<AllocationRef>,
316
317 /// When the allocation was matched to a Process.
318 #[serde(default, skip_serializing_if = "Option::is_none")]
319 pub allocated_at: Option<DateTime<Utc>>,
320
321 /// Wall-clock expiry derived from `spec.ttl` + `allocated_at`.
322 /// The pool reconciler force-returns the member at this point.
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub expires_at: Option<DateTime<Utc>>,
325
326 /// Operator-visible message.
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub message: Option<String>,
329
330 /// Standard Conditions.
331 ///
332 /// The empty case is skipped at serialization so a merge-patch
333 /// body built from a caller-supplied [`AllocationStatus`] whose
334 /// `conditions` slot has not been touched does NOT emit
335 /// `"conditions": []` on the wire — under RFC-7396 JSON Merge
336 /// Patch (the shape `Patch::Merge` sends) an empty array
337 /// REPLACES the persisted list rather than merges into it, so a
338 /// controller round-trip that reused a scratch `AllocationStatus`
339 /// as a patch body would silently clobber whatever conditions the
340 /// prior status carried. Peer to `phase_since` /
341 /// `bound_pool` / `assigned_process` / `allocated_at` /
342 /// `expires_at` above, each already skip-serialized on its
343 /// [`Default`]-equivalent variant.
344 #[serde(default, skip_serializing_if = "Vec::is_empty")]
345 pub conditions: Vec<AllocationCondition>,
346}
347
348impl AllocationStatus {
349 /// Substrate composer for a phase-transition [`AllocationStatus`]
350 /// seed: stamps the THREE always-present slots (`phase` +
351 /// `phase_since = Some(now)` + `message = Some(<supplied>)`) and
352 /// defaults every other slot (`bound_pool` / `assigned_process` /
353 /// `allocated_at` / `expires_at` = `None`, `conditions = vec![]`).
354 /// Caller-branches attach the extra slots via struct-update
355 /// syntax onto the seed.
356 ///
357 /// Pre-lift the 4-slot phase-transition seed
358 /// ```rust,ignore
359 /// json!({
360 /// "status": {
361 /// "phase": <AllocationPhase-variant>,
362 /// "phaseSince": Utc::now(),
363 /// "message": "<transition-reason>",
364 /// …optional caller-attached slots…
365 /// }
366 /// })
367 /// ```
368 /// was hand-authored at FOUR sites past the ★★ PRIME-DIRECTIVE
369 /// ≥ 2 duplication threshold in
370 /// `tatara-pool-reconciler::controller_allocation::reconcile_inner`,
371 /// each restating the SAME `phase + phase_since + message` invariant
372 /// triplet on a different [`AllocationPhase`] variant:
373 /// * `AllocationDecision::NoMatchingPool` — the "no Pool selector
374 /// matched this Requestor" fallthrough
375 /// ([`AllocationPhase::NoMatchingPool`]).
376 /// * `AllocationDecision::Wait` — the "pool matched; no Free member
377 /// available" queued path
378 /// ([`AllocationPhase::Queued`]) with a `bound_pool` addition.
379 /// * `AllocationDecision::Bind` — the "bound to pool member"
380 /// allocation path ([`AllocationPhase::Bound`]) with
381 /// `bound_pool` + `assigned_process` + `allocated_at` +
382 /// `expires_at` additions.
383 /// * `AllocationDecision::Release` — the "released; pool reconciler
384 /// will return the member" release path
385 /// ([`AllocationPhase::Released`]) with `bound_pool` +
386 /// `assigned_process` additions.
387 ///
388 /// All four hand-authored the SAME `phaseSince: Utc::now()` stamp
389 /// alongside the phase transition, and all four spelled the
390 /// invariant triplet as bare JSON keys inside a `json!({...})`
391 /// literal — a fragile shape where any drift in the underlying
392 /// [`AllocationStatus`] field naming (a rename from `phaseSince`
393 /// to `phase_since` at the serde surface, a promotion of `message`
394 /// to a structured envelope) silently stops the JSON keys from
395 /// mapping to the typed struct's fields and the K8s API server
396 /// merges an ill-shaped patch. Post-lift the four callers build a
397 /// typed [`AllocationStatus`] via `AllocationStatus::transition`,
398 /// attach any branch-specific slots via struct-update syntax, and
399 /// wrap the result in `json!({ "status": s })` — the serde
400 /// `rename_all = "camelCase"` derive on [`AllocationStatus`] owns
401 /// the wire-shape composition, so a field rename lands at ONE
402 /// site (the derive) and every emit site inherits the upgrade
403 /// mechanically.
404 ///
405 /// Cross-CRD peer to [`crate::pool::PoolStatus::observed`] on the
406 /// same `<CRD>Status` substrate-composer axis — both primitives
407 /// stamp `phase_since = Some(now)` from a caller-supplied `now`
408 /// timestamp so the composer stays clock-injectable rather than
409 /// implicitly reading wall time, and both close every optional slot
410 /// with its [`Default`]-equivalent variant so a future slot
411 /// addition on either status shape plugs into the composer at ONE
412 /// site and every downstream emit site inherits the new slot
413 /// mechanically.
414 ///
415 /// Cross-CRD peer to the `tatara-reconciler::patch::phase_status_msg`
416 /// primitive on the (CRD × phase-transition-with-message) axis —
417 /// both primitives own the three-slot `phase + phase_since +
418 /// message` invariant on their respective CRDs' status subresource,
419 /// and both accept `impl Into<String>` for the message so the
420 /// callsite carries `&'static str` literal reasons and
421 /// `format!(...)`-owned strings without widening the signature.
422 ///
423 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
424 /// the 4-slot phase-transition status-seed incantation recurred at
425 /// four hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
426 /// duplication trigger, and is lifted to ONE owner here).
427 /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
428 /// the pins bind the three always-present slots + the
429 /// [`Default`]-defaulted rest + byte-identical parity with the
430 /// pre-lift `json!({...})` triplet through serde round-trip, so a
431 /// regression that drifted any surface at
432 /// `tests::allocation_status_transition_*` rather than as silent
433 /// operator-visible skew between the four allocation-decision
434 /// patch sites).
435 #[must_use]
436 pub fn transition(
437 phase: AllocationPhase,
438 message: impl Into<String>,
439 now: DateTime<Utc>,
440 ) -> Self {
441 Self {
442 phase,
443 phase_since: Some(now),
444 message: Some(message.into()),
445 ..Default::default()
446 }
447 }
448
449 /// Substrate composer for a phase-transition [`AllocationStatus`]
450 /// seed whose `bound_pool` + `assigned_process` axis-pair is
451 /// stamped alongside the base [`Self::transition`] triplet
452 /// (`phase` + `phase_since = Some(now)` + `message =
453 /// Some(<supplied>)`). Every other slot lands at its
454 /// [`Default`]-equivalent variant so a caller-branch that attaches
455 /// an optional slot via struct-update syntax (a `Bind` arm's
456 /// `allocated_at` / `expires_at` addenda, say) does not silently
457 /// inherit a pre-populated non-`None` value.
458 ///
459 /// Pre-lift the `bound_pool: Some(pool)` + `assigned_process:
460 /// Some(AllocationRef::new(name, ns))` pair rode struct-update
461 /// syntax onto [`Self::transition`] at TWO sites past the ★★
462 /// PRIME-DIRECTIVE ≥ 2 duplication threshold in
463 /// `tatara-pool-reconciler::controller_allocation::reconcile_inner`
464 /// — the `AllocationDecision::Bind` arm ([`AllocationPhase::Bound`]
465 /// with two extra `allocated_at` / `expires_at` addenda) and the
466 /// `AllocationDecision::Release` arm ([`AllocationPhase::Released`]
467 /// with no addenda). Both restated the SAME pair-of-`Some`-slot
468 /// invariant against the SAME struct-update seed and funneled the
469 /// resulting body through the SAME `patch_status` call on
470 /// `Api<EphemeralAllocation>`. Post-lift both callers reach the
471 /// pair through ONE substrate composer; a future normalization on
472 /// the bound-set axis (a symmetry gate that the assigned_process's
473 /// namespace matches the bound_pool's namespace, a canonicalization
474 /// that closes the pair against a stale audit record, a
475 /// backwards-compatibility rename of either slot at the serde
476 /// surface) lands at ONE substrate site rather than at each
477 /// callsite in the two-arm allocation reconciler.
478 ///
479 /// Composes atop [`Self::transition`] so any future evolution to
480 /// the base three-slot invariant triplet (a `phase_since` rename,
481 /// a `message` promotion to a structured envelope, a fourth
482 /// always-stamped diagnostic slot) reaches this composer through
483 /// ONE substrate site and both consumers inherit the upgrade
484 /// mechanically. Sibling composition discipline to
485 /// [`crate::pool::PoolStatus::observed`]'s `state_count_fanout` +
486 /// `Utc::now()` fold — the compound composer names its axis + calls
487 /// the substrate primitive on the invariant it wraps rather than
488 /// restating the wrapped shape inline.
489 ///
490 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
491 /// the `bound_pool + assigned_process` pair recurred at two hand-
492 /// authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
493 /// trigger, and is lifted to ONE owner here). THEORY.md §II.1
494 /// invariant 5 (composition preserves proofs — the pins bind the
495 /// pair + the composed base triplet + byte-identical parity with
496 /// the pre-lift struct-update shape through serde round-trip, so a
497 /// regression that drifted any surface at
498 /// `tests::allocation_status_bound_transition_*` rather than as
499 /// silent operator-visible skew between the two Bind / Release
500 /// patch sites).
501 #[must_use]
502 pub fn bound_transition(
503 phase: AllocationPhase,
504 message: impl Into<String>,
505 now: DateTime<Utc>,
506 bound_pool: AllocationRef,
507 assigned_process: AllocationRef,
508 ) -> Self {
509 Self {
510 bound_pool: Some(bound_pool),
511 assigned_process: Some(assigned_process),
512 ..Self::transition(phase, message, now)
513 }
514 }
515}
516
517/// Allocation lifecycle phase.
518///
519/// Sibling closed-set lifts on the same `EphemeralAllocation` /
520/// `EphemeralPool` axis: [`crate::pool::ReplacementPolicy::ALL`],
521/// [`crate::pool::ReturnPolicy::ALL`]. Sibling closed-sets on the
522/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`],
523/// [`crate::lifetime::LifetimeKind::ALL`],
524/// [`crate::boundary::ConditionKind::ALL`],
525/// [`crate::intent::IntentKind::ALL`],
526/// [`crate::phase::ProcessPhase::ALL`],
527/// [`crate::signal::ProcessSignal::ALL`].
528#[derive(
529 Clone,
530 Copy,
531 Debug,
532 PartialEq,
533 Eq,
534 Hash,
535 Serialize,
536 Deserialize,
537 JsonSchema,
538 tatara_closed_set::DeriveClosedSet,
539)]
540#[serde(rename_all = "PascalCase")]
541#[closed_set(via = "as_str", generate_unknown, display)]
542pub enum AllocationPhase {
543 /// Admitted; pool selector matching not yet attempted.
544 Pending,
545 /// Routed to a pool but no `Free` member is available — queued.
546 Queued,
547 /// A pool member has been assigned + transitioned to Allocated.
548 Bound,
549 /// `expires_at` reached or requestor deleted; member is returning.
550 Releasing,
551 /// Released; the allocation is a permanent audit record.
552 Released,
553 /// No pool selector matched. The reconciler will retry on each
554 /// pool spec update; surfaced in status so operators see why.
555 NoMatchingPool,
556 /// Pool refused (e.g., `max_size` reached and no member can be
557 /// freed) — operator intervention needed.
558 Failed,
559}
560
561impl Default for AllocationPhase {
562 fn default() -> Self {
563 Self::Pending
564 }
565}
566
567impl AllocationPhase {
568 /// The closed set of allocation phases — single source of truth
569 /// that drives the `as_str` / Display / `FromStr` triad AND the
570 /// `is_terminal` / `needs_pool_routing` predicate pair the
571 /// allocation reconciler's observe/decide split dispatches on.
572 /// Adding an eighth variant lands at one `ALL` entry + one
573 /// `as_str` arm + one arm per predicate — exhaustively checked by
574 /// the compiler (the `[Self; 7]` array literal forces the arity)
575 /// and by the implication test
576 /// (`allocation_phase_terminal_excludes_routing`) so a new
577 /// variant can't claim to be both terminal AND routing-eligible.
578 pub const ALL: [Self; 7] = [
579 Self::Pending,
580 Self::Queued,
581 Self::Bound,
582 Self::Releasing,
583 Self::Released,
584 Self::NoMatchingPool,
585 Self::Failed,
586 ];
587
588 /// Canonical PascalCase wire-format projection — matches the
589 /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
590 /// `enum:` enumeration the allocation reconciler stamps on the
591 /// `ephemeralallocations.tatara.pleme.io` schema. Pinned by
592 /// `allocation_phase_as_str_matches_serde` so a variant rename
593 /// can't drift between the typed surface, the CRD enum, the YAML
594 /// wire format AND any operator-facing diagnostic composed via
595 /// Display rather than a hard-coded literal that would silently
596 /// rot.
597 pub const fn as_str(self) -> &'static str {
598 match self {
599 Self::Pending => "Pending",
600 Self::Queued => "Queued",
601 Self::Bound => "Bound",
602 Self::Releasing => "Releasing",
603 Self::Released => "Released",
604 Self::NoMatchingPool => "NoMatchingPool",
605 Self::Failed => "Failed",
606 }
607 }
608
609 /// True iff the allocation has reached an absorbing state —
610 /// `Released` (clean audit record) or `Failed` (pool refused;
611 /// operator intervention needed). The allocation reconciler
612 /// short-circuits both phases to `NoOp` rather than re-running
613 /// the routing / heartbeat ladder against a settled record.
614 ///
615 /// Closed-set match (not `matches!`) so a future variant
616 /// triggers the compiler's exhaustiveness check at this site
617 /// rather than silently defaulting to `false` and letting a new
618 /// terminal phase fall through into pool rebinding. Paired with
619 /// `needs_pool_routing` they form the two-axis projection
620 /// `allocation_decide::AllocationConvergence::decide` matches
621 /// against — the impossible bucket `(true, true)` is pinned
622 /// empty by `allocation_phase_terminal_excludes_routing`.
623 pub const fn is_terminal(self) -> bool {
624 match self {
625 Self::Released | Self::Failed => true,
626 Self::Pending | Self::Queued | Self::Bound | Self::Releasing | Self::NoMatchingPool => {
627 false
628 }
629 }
630 }
631
632 /// True iff the allocation is on the routing path — the
633 /// reconciler still needs to resolve a target pool + look up a
634 /// free member. `Pending` (just admitted), `Queued` (matched
635 /// pool was full last tick), and `NoMatchingPool` (no selector
636 /// matched yet; retry on pool spec updates) all live here. The
637 /// settled non-terminal phases `Bound` (already matched) and
638 /// `Releasing` (being torn down) don't — they short-circuit to
639 /// the heartbeat / release ladder without re-resolving the pool.
640 ///
641 /// Closed-set match (not `matches!`) — same exhaustiveness
642 /// discipline as [`Self::is_terminal`]. Lifts the open-coded
643 /// `phase != Released && phase != Bound` gate that
644 /// `allocation_decide::AllocationConvergenceCtx::observe` used
645 /// to predicate pool resolution on, AND closes the latent gap
646 /// where `Failed` / `Releasing` (neither `Released` nor `Bound`)
647 /// would slip through to the routing branch — a `Failed`
648 /// allocation without a deletion timestamp could be silently
649 /// rebound to a fresh pool member, which is the opposite of
650 /// "operator intervention needed."
651 pub const fn needs_pool_routing(self) -> bool {
652 match self {
653 Self::Pending | Self::Queued | Self::NoMatchingPool => true,
654 Self::Bound | Self::Releasing | Self::Released | Self::Failed => false,
655 }
656 }
657}
658
659// `impl FromStr for AllocationPhase` + `impl tatara_lisp::ClosedSet for
660// AllocationPhase` + `impl std::fmt::Display for AllocationPhase` are
661// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
662// declaration above. `label` delegates to the inherent
663// `AllocationPhase::as_str` via `#[closed_set(via = "as_str")]` so the
664// PascalCase wire-format projection stays load-bearing (matches the serde
665// rename + the CRD `enum:` enumeration the allocation reconciler stamps
666// on the `ephemeralallocations.tatara.pleme.io` schema verbatim) while
667// generic `T: ClosedSet` consumers reach the STABLE workspace-wide name
668// (`label`). The `display` flag emits the `f.write_str(self.as_str())`
669// delegation block at the same proc-macro site rather than a
670// hand-rolled `fmt::Display` block per implementor.
671
672// `pub struct UnknownAllocationPhase(pub String)` is generated by
673// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
674// on the enum declaration above. The auto-derived label `"allocation phase"`
675// matches the prior hand-rolled `#[error("unknown allocation phase: {0}")]`
676// verbatim — pinned generically by clause (5) of
677// `tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>()` (called
678// from `allocation_phase_is_well_formed_closed_set` in the test module).
679// Symmetric to [`crate::pool::UnknownReplacementPolicy`],
680// [`crate::pool::UnknownReturnPolicy`],
681// [`crate::lifetime::UnknownTeardownPolicy`],
682// [`crate::boundary::UnknownConditionKind`], and
683// [`crate::phase::UnknownPhase`].
684
685/// Allocation Condition (same shape as PoolCondition for downstream
686/// uniformity).
687#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
688#[serde(rename_all = "camelCase")]
689pub struct AllocationCondition {
690 pub type_: String,
691 pub status: String,
692 pub reason: String,
693 pub message: String,
694 pub last_transition_time: DateTime<Utc>,
695}
696
697impl EphemeralAllocation {
698 /// The copy-form status-projection primitive on the phase axis:
699 /// returns the [`AllocationPhase`] the pool reconciler currently
700 /// persists at `status.phase`, wrapped in an `Option` so the
701 /// missing-`status` corner collapses to `None` — the ONE-liner
702 /// collapse of the paired `self.status.as_ref().map(|s| s.phase)`
703 /// incantation the pool reconciler's `AllocationConvergenceCtx::
704 /// observe` restated by hand pre-lift.
705 ///
706 /// Cross-CRD peer to [`crate::prelude::Process::observed_phase`]
707 /// on the (CRD × phase-slot × observed-status) axis pair — both
708 /// primitives walk the identical `.status.as_ref().map(|s| s.
709 /// phase)` shape, differing only in the `Phase` type projected
710 /// ([`AllocationPhase`] vs [`crate::phase::ProcessPhase`]). The
711 /// substrate now owns the borrow-form `.status.as_ref().map(|s|
712 /// s.phase)` chain axis-uniformly across the two `Phase`-having
713 /// CRDs so a future normalization (a generation-filter that
714 /// returns `None` for a phase stamped with a stale
715 /// `metadata.generation`, a staleness gate that drops a phase
716 /// whose observing `phase_since` predates a reconcile deadline,
717 /// a canonicalization pass that maps a phase outside the CRD's
718 /// closed set to `None`) lands at ONE substrate method per CRD
719 /// rather than being restated at every observer.
720 #[must_use]
721 pub fn observed_phase(&self) -> Option<AllocationPhase> {
722 self.status.as_ref().map(|s| s.phase)
723 }
724
725 /// The copy-form status-projection primitive on the phase axis
726 /// with the [`AllocationPhase::Pending`] sink applied — the
727 /// ONE-liner collapse of the paired `self.observed_phase().
728 /// unwrap_or(AllocationPhase::Pending)` incantation the pool
729 /// reconciler's `AllocationConvergenceCtx::observe` restated by
730 /// hand pre-lift as a 5-line `.status.as_ref().map(|s| s.phase).
731 /// unwrap_or(AllocationPhase::Pending)` chain.
732 ///
733 /// Pre-lift the chain sat at [`tatara-pool-reconciler::
734 /// allocation_decide::AllocationConvergenceCtx::observe`]'s
735 /// `phase` seed. Cross-CRD peer to [`crate::prelude::Process::
736 /// observed_phase_or_pending`] on the (CRD × phase-slot × sink)
737 /// axis pair — both primitives close the missing-`status`
738 /// corner with each CRD's respective [`Default`]-equivalent
739 /// `Pending` variant, and both compose on top of their peer
740 /// [`Self::observed_phase`] / [`crate::prelude::Process::
741 /// observed_phase`] borrow-form projections so a future
742 /// normalization at the underlying `observed_phase` primitive
743 /// reaches both the raw-`Option` accessor and the `Pending`-
744 /// sinked composer through the SAME upstream body.
745 ///
746 /// The [`AllocationPhase::Pending`] sink is load-bearing as the
747 /// "not yet observed" default — the pool reconciler's typed
748 /// `AllocationPhase::needs_pool_routing` predicate returns
749 /// `true` for `Pending`, so a freshly-admitted Allocation whose
750 /// pool reconciler has not yet stamped a `.status` slot reads
751 /// as `Pending` and immediately enters the routing ladder,
752 /// matching the pre-lift `AllocationPhase::Pending` fallback
753 /// semantics verbatim.
754 ///
755 /// Theory anchor: THEORY.md §VI.1 (generation over composition
756 /// — the two-link `.status.as_ref().map(|s| s.phase).unwrap_or
757 /// (AllocationPhase::Pending)` chain recurred at both the
758 /// [`crate::prelude::Process`] site (already lifted onto
759 /// [`crate::prelude::Process::observed_phase_or_pending`]) AND
760 /// the [`EphemeralAllocation`] site by hand, i.e. the SHAPE
761 /// itself recurs past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
762 /// trigger, and is lifted to ONE owner per CRD here). THEORY.md
763 /// §II.1 invariant 5 (composition preserves proofs — the pins
764 /// bind the missing-`status` sink to `Pending` + populated-
765 /// status pass-through + every [`AllocationPhase`] variant
766 /// round-trip + byte-identical parity with the pre-lift
767 /// two-link chain + cross-CRD peer coherence with
768 /// [`crate::prelude::Process::observed_phase_or_pending`], so
769 /// a regression that drifted any surface at
770 /// `tests::observed_phase_*` rather than as silent operator-
771 /// facing skew between the allocation observer's routing seed
772 /// and the Process observer's dispatch seed).
773 #[must_use]
774 pub fn observed_phase_or_pending(&self) -> AllocationPhase {
775 self.observed_phase().unwrap_or(AllocationPhase::Pending)
776 }
777
778 /// The borrow-form status-projection primitive on the bound-pool
779 /// axis: returns the [`AllocationRef`] the pool reconciler
780 /// currently persists at `status.bound_pool` (name + namespace of
781 /// the pool that owns the matched member), with the
782 /// missing-`status` corner AND the empty-slot corner BOTH
783 /// collapsed to `None` — the ONE-liner collapse of the paired
784 /// `self.status.as_ref().and_then(|s| s.bound_pool.<clone|as_ref>())`
785 /// incantation the pool reconciler's `AllocationConvergenceCtx::
786 /// observe` restated by hand pre-lift.
787 ///
788 /// Cross-CRD peer to [`crate::prelude::Process::observed_identity`]
789 /// on the (CRD × structured-record-slot × borrow-form) axis pair
790 /// — both primitives walk the identical `.status.as_ref()
791 /// .and_then(|s| s.<slot>.as_ref())` shape, differing only in the
792 /// record projected ([`AllocationRef`] here, [`crate::identity::
793 /// Identity`] on `Process`). The substrate now owns the
794 /// borrow-form `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
795 /// chain on the second `structured-record` slot across the two
796 /// `status`-having CRDs, so a future normalization step (a
797 /// generation-filter that returns `None` for a bound-pool
798 /// reference stamped with a stale `metadata.generation`, a
799 /// canonicalization pass that rejects a malformed
800 /// `(name, namespace)` pair, a cross-cluster reference-rewrite
801 /// gate) lands at ONE substrate method per CRD rather than being
802 /// restated at every observer.
803 ///
804 /// Return-form axis: `Option<&AllocationRef>` mirrors the
805 /// borrow-first discipline of [`crate::prelude::Process::
806 /// observed_identity`]. The lone pre-lift consumer
807 /// ([`tatara-pool-reconciler::allocation_decide::
808 /// AllocationConvergenceCtx::observe`]'s `bound_pool` seed) spelled
809 /// the projection as `.and_then(|s| s.bound_pool.clone())` — an
810 /// eager clone allocated inside every reconcile pass even when the
811 /// downstream branch (the Release-composition arm) needed only the
812 /// borrow for the `.as_ref()` re-projection two lines later.
813 /// Post-lift the consumer reaches the primitive borrow-first
814 /// (`alloc.observed_bound_pool().cloned()`) and the empty-borrow
815 /// corner clones nothing (`Option::cloned` on `None` is `None`);
816 /// the composition point where the owned `AllocationRef` fallback
817 /// is required (the `AllocationConvergenceCtx` snapshot slot,
818 /// still `Option<AllocationRef>`-typed for serde stability) is the
819 /// ONLY site that materializes an owned copy.
820 ///
821 /// The missing-`status` corner AND the populated-status-with-
822 /// `bound_pool=None` corner BOTH collapse to `None` so
823 /// `.is_some()` / `if let Some(_)` / `.cloned()` behave
824 /// identically on an `EphemeralAllocation` whose status field is
825 /// `None` and on one whose status carries an unpopulated
826 /// `bound_pool` slot — matching what the pre-lift `.and_then(...)`
827 /// chain produced. Consumers that need to tell those corners
828 /// apart reach for [`Self::status`] directly, exactly as the
829 /// existing peer accessors [`Self::observed_phase`] +
830 /// [`Self::observed_phase_or_pending`] admit.
831 ///
832 /// Theory anchor: THEORY.md §VI.1 (generation over composition
833 /// — the `.status.as_ref().and_then(|s| s.<structured-record>
834 /// .<clone|as_ref>())` shape recurred as ONE hand-authored
835 /// `.and_then(|s| s.bound_pool.clone())` chain in
836 /// [`tatara-pool-reconciler::allocation_decide::
837 /// AllocationConvergenceCtx::observe`] AND as the peer
838 /// [`crate::prelude::Process::observed_identity`] primitive
839 /// already owned on the `Process` CRD's `status.identity` slot,
840 /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger at
841 /// substrate-shape level. THEORY.md §II.1 invariant 5
842 /// (composition preserves proofs — the pins bind the missing-
843 /// `status` corner + the empty-`bound_pool`-slot corner + the
844 /// borrow-form `&AllocationRef` lifetime + the zero-copy
845 /// projection contract + byte-identical parity with the pre-lift
846 /// `.and_then(|s| s.bound_pool.clone())` chain across the full
847 /// corner set + cross-CRD peer coherence with
848 /// [`crate::prelude::Process::observed_identity`], so a
849 /// regression that drifted any surface at
850 /// `tests::observed_bound_pool_*` rather than as silent operator-
851 /// facing skew between the allocation observer's Release-
852 /// composition seed and the Process observer's FORK-time
853 /// identity seed on the SAME reconcile tick).
854 #[must_use]
855 pub fn observed_bound_pool(&self) -> Option<&AllocationRef> {
856 self.status.as_ref().and_then(|s| s.bound_pool.as_ref())
857 }
858
859 /// The copy-form status-projection primitive on the TTL-expiry axis:
860 /// returns the wall-clock deadline the pool reconciler currently
861 /// persists at `status.expires_at` (derived from `spec.ttl` +
862 /// `allocated_at` at Bind time), wrapped in an `Option` so both the
863 /// missing-`status` corner AND the populated-status-with-`expires_at
864 /// =None` corner collapse to `None` — the ONE-liner collapse of the
865 /// paired `self.status.as_ref().and_then(|s| s.expires_at)`
866 /// incantation the pool reconciler's `AllocationConvergenceCtx::
867 /// observe` restated by hand pre-lift.
868 ///
869 /// Same-CRD peer to [`Self::observed_phase`] on the (CRD × copy-form
870 /// × status-slot) axis pair — both primitives walk the identical
871 /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape,
872 /// differing only in the record projected ([`DateTime<Utc>`] here,
873 /// [`AllocationPhase`] on the phase axis) and in the outer combinator
874 /// (`and_then` here because the persisted field is itself an
875 /// `Option<DateTime<Utc>>`, `map` there because the persisted phase
876 /// is bare). The substrate now owns the copy-form
877 /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` chain
878 /// axis-uniformly across every `Copy`-valued slot on
879 /// `AllocationStatus`, so a future normalization (a clock-skew
880 /// guard that drops an `expires_at` stamped before its owning
881 /// allocation's observed `allocated_at`, a canonicalization pass
882 /// that clamps a deadline to a monotonic upper bound, a stale-
883 /// timestamp gate that returns `None` on an `expires_at` older than
884 /// a controller-configured horizon) lands at ONE substrate method
885 /// rather than being restated at every observer.
886 ///
887 /// Return-form axis: `Option<DateTime<Utc>>` mirrors the copy-first
888 /// discipline of [`Self::observed_phase`]. The lone pre-lift consumer
889 /// ([`tatara-pool-reconciler::allocation_decide::
890 /// AllocationConvergenceCtx::observe`]'s `expires_at` seed) spelled
891 /// the projection as `.status.as_ref().and_then(|s| s.expires_at)` —
892 /// a 3-link hand-authored chain the observer walked on every
893 /// reconcile pass. Post-lift the consumer reaches the primitive
894 /// once and the whole missing-status + empty-slot corner cross
895 /// collapses at the substrate rather than at the callsite.
896 ///
897 /// The missing-`status` corner AND the populated-status-with-
898 /// `expires_at=None` corner BOTH collapse to `None` so
899 /// `.is_some()` / `if let Some(_)` / any `>=` deadline comparison
900 /// behave identically on an `EphemeralAllocation` whose status
901 /// field is `None` and on one whose status carries an unpopulated
902 /// `expires_at` slot — matching what the pre-lift `.and_then(...)`
903 /// chain produced. Consumers that need to tell those corners apart
904 /// reach for [`Self::status`] directly, exactly as the existing peer
905 /// accessors [`Self::observed_phase`] +
906 /// [`Self::observed_phase_or_pending`] admit.
907 ///
908 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
909 /// the `.status.as_ref().and_then(|s| s.<Copy-field>)` shape
910 /// recurred as ONE hand-authored chain in
911 /// [`tatara-pool-reconciler::allocation_decide::
912 /// AllocationConvergenceCtx::observe`] AND as the copy-form peer
913 /// [`Self::observed_phase`] primitive already owned on the same
914 /// CRD's `status.phase` slot, past the substrate-shape recurrence
915 /// trigger; the substrate now owns the third status-projection
916 /// primitive on `EphemeralAllocation`, closing the copy-form family
917 /// alongside the borrow-form [`Self::observed_bound_pool`]).
918 /// THEORY.md §II.1 invariant 5 (composition preserves proofs — the
919 /// pins bind the missing-`status` corner + the empty-`expires_at`-
920 /// slot corner + the copy-form `DateTime<Utc>` return + byte-
921 /// identical parity with the pre-lift `.and_then(|s| s.expires_at)`
922 /// chain across the full corner set, so a regression that drifted
923 /// any surface surfaces at `tests::observed_expires_at_*` rather
924 /// than as silent operator-facing skew between the allocation
925 /// observer's Release-composition TTL gate and any future consumer
926 /// that reaches for the same slot).
927 #[must_use]
928 pub fn observed_expires_at(&self) -> Option<DateTime<Utc>> {
929 self.status.as_ref().and_then(|s| s.expires_at)
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 // `FromStr` lives in scope at the test surface only — the derive
936 // emits `impl ::core::str::FromStr` via the full path so the lib
937 // body no longer reaches `FromStr` directly, but the cross-axis
938 // sweeps + the verbatim-echo contract tests call
939 // `AllocationPhase::from_str(bad)` / `bad.parse::<RequestorKind>()`.
940 use std::str::FromStr;
941
942 use super::*;
943
944 #[test]
945 fn requestor_minimum_shape_round_trips() {
946 let r = Requestor {
947 kind: "github-pr".into(),
948 repo: Some("pleme-io/demo-app".into()),
949 branch: Some("fix-something".into()),
950 pr_number: Some(123),
951 sha: Some("abc123def".into()),
952 pr_labels: vec!["needs-ephemeral".into()],
953 actor: Some("drzln".into()),
954 };
955 let yaml = serde_yaml::to_string(&r).unwrap();
956 assert!(yaml.contains("kind: github-pr"));
957 assert!(yaml.contains("prNumber: 123"));
958 let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
959 assert_eq!(back.kind, "github-pr");
960 assert_eq!(back.pr_number, Some(123));
961 }
962
963 #[test]
964 fn allocation_status_defaults_pending() {
965 let s = AllocationStatus::default();
966 assert_eq!(s.phase, AllocationPhase::Pending);
967 assert!(s.bound_pool.is_none());
968 assert!(s.assigned_process.is_none());
969 }
970
971 #[test]
972 fn allocation_phase_round_trips_via_serde() {
973 for p in [
974 AllocationPhase::Pending,
975 AllocationPhase::Queued,
976 AllocationPhase::Bound,
977 AllocationPhase::Releasing,
978 AllocationPhase::Released,
979 AllocationPhase::NoMatchingPool,
980 AllocationPhase::Failed,
981 ] {
982 let s = serde_yaml::to_string(&p).unwrap();
983 let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
984 assert_eq!(back, p);
985 }
986 }
987
988 // ── closed-set algebra contracts for AllocationPhase
989 // (ALL × as_str × FromStr × predicate-pair) ────────────────────
990
991 /// `ALL` is the source of truth — pin its closure so a variant
992 /// added without an `ALL` entry fails here via the uniqueness
993 /// check before drifting `FromStr` or the sweep tests below. The
994 /// arity is asserted by the `[Self; 7]` array type itself.
995 ///
996 /// Structural well-formedness of [`AllocationPhase`] as a
997 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
998 /// testkit lift that pins all three structural invariants
999 /// (`ALL` is non-empty, every variant round-trips through
1000 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1001 /// outside the closed set) at ONE call site. Replaces the hand-
1002 /// derived `allocation_phase_all_is_unique_and_complete` +
1003 /// `allocation_phase_roundtrip_via_as_str` + the empty-input arm
1004 /// of `unknown_allocation_phase_errors`. `FromStr` delegates to
1005 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
1006 /// helper exercises the same code path the allocation reconciler
1007 /// hits when parsing a CRD `enum:`-validated value back to the
1008 /// typed phase.
1009 #[test]
1010 fn allocation_phase_is_well_formed_closed_set() {
1011 tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>();
1012 }
1013
1014 /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
1015 /// output verbatim for every variant. A future variant rename
1016 /// (or an `as_str` arm typo) lands here at one site, instead of
1017 /// drifting between the typed surface, the CRD enum, the YAML
1018 /// wire format, and the operator-facing reason strings the
1019 /// reconciler stamps via Display.
1020 #[test]
1021 fn allocation_phase_as_str_matches_serde() {
1022 crate::tagged_union::assert_label_matches_serde_serialization::<AllocationPhase>();
1023 }
1024
1025 /// The Display impl IS `as_str` — pinning this lets future
1026 /// callers reach for either projection without drift.
1027 #[test]
1028 fn allocation_phase_display_matches_as_str() {
1029 crate::tagged_union::assert_display_matches_label::<AllocationPhase>();
1030 }
1031
1032 /// `FromStr` rejects strings that aren't in the canonical
1033 /// projection — lowercased / typo / unrelated — and the error
1034 /// echoes the input verbatim so the operator-facing diagnostic
1035 /// carries the offending value, not a normalized form. The
1036 /// empty-input arm is pinned by
1037 /// [`allocation_phase_is_well_formed_closed_set`] via the
1038 /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
1039 /// verbatim-echo contract on the [`UnknownAllocationPhase`]
1040 /// newtype, which the trait's `make_unknown` can't see.
1041 #[test]
1042 fn unknown_allocation_phase_errors() {
1043 for bad in [
1044 "pending",
1045 "BOUND",
1046 "no-matching-pool",
1047 "release",
1048 "failed_state",
1049 "Reaped",
1050 ] {
1051 let err = AllocationPhase::from_str(bad).unwrap_err();
1052 assert_eq!(err.0, bad, "error payload should echo input verbatim");
1053 }
1054 }
1055
1056 /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
1057 /// documented per-variant disposition. `Released` + `Failed` are
1058 /// terminal (absorbing); `Pending` / `Queued` / `NoMatchingPool`
1059 /// need pool routing; `Bound` / `Releasing` are settled-but-not-
1060 /// terminal (heartbeat / release ladder).
1061 #[test]
1062 fn allocation_phase_predicate_truth_tables() {
1063 assert!(!AllocationPhase::Pending.is_terminal());
1064 assert!(AllocationPhase::Pending.needs_pool_routing());
1065
1066 assert!(!AllocationPhase::Queued.is_terminal());
1067 assert!(AllocationPhase::Queued.needs_pool_routing());
1068
1069 assert!(!AllocationPhase::Bound.is_terminal());
1070 assert!(!AllocationPhase::Bound.needs_pool_routing());
1071
1072 assert!(!AllocationPhase::Releasing.is_terminal());
1073 assert!(!AllocationPhase::Releasing.needs_pool_routing());
1074
1075 assert!(AllocationPhase::Released.is_terminal());
1076 assert!(!AllocationPhase::Released.needs_pool_routing());
1077
1078 assert!(!AllocationPhase::NoMatchingPool.is_terminal());
1079 assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
1080
1081 assert!(AllocationPhase::Failed.is_terminal());
1082 assert!(!AllocationPhase::Failed.needs_pool_routing());
1083 }
1084
1085 /// IMPLICATION CONTRACT: `is_terminal → !needs_pool_routing`. A
1086 /// terminal allocation cannot also be routing-eligible — that's
1087 /// the bug the typed projection closes (a `Failed` allocation
1088 /// that's neither `Released` nor `Bound` would otherwise slip
1089 /// through the open-coded gate in `observe` and try to rebind to
1090 /// a pool member). A future variant that flipped both predicates
1091 /// true would fail here, forcing the author to flip one or
1092 /// extend the consumer dispatch site in
1093 /// `tatara-pool-reconciler::allocation_decide` deliberately
1094 /// rather than letting an impossible state slip in.
1095 #[test]
1096 fn allocation_phase_terminal_excludes_routing() {
1097 for phase in AllocationPhase::ALL {
1098 assert!(
1099 !(phase.is_terminal() && phase.needs_pool_routing()),
1100 "{phase:?} is both terminal and routing-eligible",
1101 );
1102 }
1103 }
1104
1105 /// DEFAULT-AGREEMENT CONTRACT: `AllocationPhase::default()` is
1106 /// `Pending` — the entry state, neither terminal nor settled —
1107 /// and it lives on the routing path. A future default-variant
1108 /// rename without flipping the predicates fails here.
1109 #[test]
1110 fn allocation_phase_default_is_pending_and_routes() {
1111 let d = AllocationPhase::default();
1112 assert_eq!(d, AllocationPhase::Pending);
1113 assert!(!d.is_terminal());
1114 assert!(d.needs_pool_routing());
1115 }
1116
1117 // ── RequestorKind closed-set truth-table ─────────────────────────
1118
1119 /// Structural well-formedness of [`RequestorKind`] as a
1120 /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
1121 /// testkit lift that pins all three structural invariants
1122 /// (`ALL` is non-empty, every variant round-trips through
1123 /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
1124 /// outside the closed set) at ONE call site. Replaces the hand-
1125 /// derived `requestor_kind_all_enumerates_each_variant_exactly_once`
1126 /// + `requestor_kind_from_str_round_trips_canonical_names` + the
1127 /// empty-input arm of `requestor_kind_from_str_rejects_open_kinds`.
1128 /// `FromStr` delegates to
1129 /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
1130 /// exercises the same code path
1131 /// [`Requestor::known_kind`]'s `Option<RequestorKind>` collapse
1132 /// rides on when classifying inbound `Requestor.kind` strings. The
1133 /// arity is asserted by the `[Self; 4]` array type itself.
1134 #[test]
1135 fn requestor_kind_is_well_formed_closed_set() {
1136 tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>();
1137 }
1138
1139 /// Byte-exact wire-format pin — renaming any of these is a wire-
1140 /// format change (the `tatara-github-watcher` emitter, the CRD
1141 /// printcolumns, the `PoolSelector.kinds` filter strings, the
1142 /// per-test `kind: "…".into()` fixtures all depend on these
1143 /// literals), not a typed-internal refactor.
1144 #[test]
1145 fn requestor_kind_canonical_names_pinned() {
1146 assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
1147 assert_eq!(RequestorKind::Manual.as_str(), "manual");
1148 assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
1149 assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
1150 }
1151
1152 /// `FromStr` rejects strings that aren't in the canonical
1153 /// projection — lowercased-mismatch / typo / unrelated — and the
1154 /// error echoes the input verbatim so the operator-facing
1155 /// diagnostic carries the offending value, not a normalized form.
1156 /// The schema is open at the wire layer (operators MAY register
1157 /// new kinds and `Requestor::known_kind` collapses them to
1158 /// `None`), but the closed-set view is byte-exact. The empty-input
1159 /// arm is pinned by [`requestor_kind_is_well_formed_closed_set`]
1160 /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
1161 /// the verbatim-echo contract on the [`UnknownRequestorKind`]
1162 /// newtype, which the trait's `make_unknown` can't see.
1163 #[test]
1164 fn requestor_kind_from_str_rejects_open_kinds() {
1165 for bad in [
1166 "github_pr",
1167 "GithubPr",
1168 "operator-custom-kind",
1169 "ci_run",
1170 "Scheduled",
1171 ] {
1172 let err = bad.parse::<RequestorKind>().unwrap_err();
1173 assert_eq!(err, UnknownRequestorKind(bad.to_string()));
1174 }
1175 }
1176
1177 /// The Display impl IS `as_str` — pinning this lets future
1178 /// callers reach for either projection without drift (Display is
1179 /// what operator-facing diagnostics compose against).
1180 #[test]
1181 fn requestor_kind_display_delegates_to_as_str() {
1182 for k in RequestorKind::ALL {
1183 assert_eq!(format!("{k}"), k.as_str());
1184 }
1185 }
1186
1187 /// The `String` projection that `From<RequestorKind> for String`
1188 /// ([`RequestorKind::into`]) composes is byte-equal to `as_str`.
1189 /// This is the typed → wire bridge — emitters spell
1190 /// `kind: RequestorKind::GithubPr.into()` and the canonical
1191 /// literal is materialized at ONE place.
1192 #[test]
1193 fn requestor_kind_into_string_matches_as_str() {
1194 for k in RequestorKind::ALL {
1195 let s: String = k.into();
1196 assert_eq!(s, k.as_str());
1197 }
1198 }
1199
1200 /// The typed → wire → typed round-trip: composing a `Requestor`
1201 /// with `kind: RequestorKind::X.into()` produces an object whose
1202 /// `known_kind()` decodes back to `X`. Pins the bridge invariant
1203 /// at the `Requestor` boundary, not just at `RequestorKind`.
1204 #[test]
1205 fn known_kind_decodes_built_requestors() {
1206 for k in RequestorKind::ALL {
1207 let r = Requestor {
1208 kind: k.into(),
1209 repo: None,
1210 branch: None,
1211 pr_number: None,
1212 sha: None,
1213 pr_labels: vec![],
1214 actor: None,
1215 };
1216 assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
1217 }
1218 }
1219
1220 /// Open-by-design: a custom operator-registered kind still
1221 /// stamps a valid `Requestor` (no schema rejection), it just
1222 /// doesn't project through the closed-set typed view. Mirrors
1223 /// `ReceiptEnvelope::known_kind`'s open-kind posture.
1224 #[test]
1225 fn known_kind_returns_none_for_open_kinds() {
1226 let r = Requestor {
1227 kind: "operator-custom-kind".into(),
1228 repo: None,
1229 branch: None,
1230 pr_number: None,
1231 sha: None,
1232 pr_labels: vec![],
1233 actor: None,
1234 };
1235 assert_eq!(r.known_kind(), None);
1236 }
1237
1238 /// The four canonical literals match every previously-published
1239 /// fixture / doc anchor in this crate — pinning the bridge to
1240 /// existing call sites so any drift fails here before the next
1241 /// release ships.
1242 #[test]
1243 fn requestor_kind_matches_existing_fixture_literals() {
1244 // The `requestor_minimum_shape_round_trips` fixture above
1245 // composes `kind: "github-pr".into()` verbatim.
1246 assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
1247 // The `allocation_spec_omits_optional_fields` fixture below
1248 // composes `kind: "manual".into()` verbatim.
1249 assert_eq!(RequestorKind::Manual.as_str(), "manual");
1250 }
1251
1252 // Per-implementor `unknown_X_message_matches_substrate_convention`
1253 // tests removed — clause (5) of
1254 // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
1255 // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
1256 // generically (called above on `RequestorKind` /
1257 // `AllocationPhase` through their `*_is_well_formed_closed_set`
1258 // sites). The `SET_LABEL` projection is pinned independently by
1259 // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
1260 // together the two contracts guarantee the operator-facing
1261 // diagnostic without needing per-enum literal pins.
1262
1263 // ─── EphemeralAllocation::observed_phase* substrate pins ────────
1264 //
1265 // Fail-before-pass-after granularity: neither `observed_phase` nor
1266 // `observed_phase_or_pending` existed before this commit, so each
1267 // pin fails to compile until the corresponding inherent method
1268 // lands. Post-lift the pins bind the missing-`status` corner + the
1269 // populated-status pass-through + byte-identical parity with the
1270 // pre-lift 5-line `.status.as_ref().map(|s| s.phase).unwrap_or
1271 // (AllocationPhase::Pending)` chain the pool reconciler's
1272 // `AllocationConvergenceCtx::observe` walked. Cross-CRD peer
1273 // coherence with `Process::observed_phase_or_pending` is pinned
1274 // by the `_matches_process_peer_shape` sweep at the tail.
1275
1276 fn alloc_with_phase(phase: AllocationPhase) -> EphemeralAllocation {
1277 let spec = AllocationSpec {
1278 pool_ref: None,
1279 requestor: Requestor {
1280 kind: "manual".into(),
1281 repo: None,
1282 branch: None,
1283 pr_number: None,
1284 sha: None,
1285 pr_labels: vec![],
1286 actor: None,
1287 },
1288 ttl: None,
1289 note: None,
1290 };
1291 let mut a = EphemeralAllocation::new("obs-alloc", spec);
1292 a.status = Some(AllocationStatus {
1293 phase,
1294 ..AllocationStatus::default()
1295 });
1296 a
1297 }
1298
1299 fn alloc_without_status() -> EphemeralAllocation {
1300 let spec = AllocationSpec {
1301 pool_ref: None,
1302 requestor: Requestor {
1303 kind: "manual".into(),
1304 repo: None,
1305 branch: None,
1306 pr_number: None,
1307 sha: None,
1308 pr_labels: vec![],
1309 actor: None,
1310 },
1311 ttl: None,
1312 note: None,
1313 };
1314 let mut a = EphemeralAllocation::new("no-status-alloc", spec);
1315 a.status = None;
1316 a
1317 }
1318
1319 #[test]
1320 fn observed_phase_returns_none_when_status_is_none() {
1321 let a = alloc_without_status();
1322 assert!(a.observed_phase().is_none());
1323 }
1324
1325 #[test]
1326 fn observed_phase_returns_populated_variant_verbatim() {
1327 for p in AllocationPhase::ALL {
1328 let a = alloc_with_phase(p);
1329 assert_eq!(
1330 a.observed_phase(),
1331 Some(p),
1332 "observed_phase must project the persisted variant verbatim for {p:?}"
1333 );
1334 }
1335 }
1336
1337 #[test]
1338 fn observed_phase_matches_pre_lift_chain_bytewise() {
1339 // Sweep every corner: (status: None) plus every populated
1340 // (status: Some(phase)) variant. The pre-lift chain was
1341 // `alloc.status.as_ref().map(|s| s.phase)` — a 3-link chain
1342 // hand-authored inline at the observer. The primitive must
1343 // return the same `Option<AllocationPhase>` on every corner.
1344 let none_alloc = alloc_without_status();
1345 assert_eq!(
1346 none_alloc.observed_phase(),
1347 none_alloc.status.as_ref().map(|s| s.phase),
1348 );
1349 for p in AllocationPhase::ALL {
1350 let a = alloc_with_phase(p);
1351 assert_eq!(
1352 a.observed_phase(),
1353 a.status.as_ref().map(|s| s.phase),
1354 "primitive must be byte-identical to the pre-lift chain for {p:?}",
1355 );
1356 }
1357 }
1358
1359 #[test]
1360 fn observed_phase_or_pending_defaults_to_pending_when_status_absent() {
1361 let a = alloc_without_status();
1362 assert_eq!(a.observed_phase_or_pending(), AllocationPhase::Pending);
1363 }
1364
1365 #[test]
1366 fn observed_phase_or_pending_returns_populated_phase_verbatim() {
1367 for p in AllocationPhase::ALL {
1368 let a = alloc_with_phase(p);
1369 assert_eq!(
1370 a.observed_phase_or_pending(),
1371 p,
1372 "populated status must pass through verbatim for {p:?}"
1373 );
1374 }
1375 }
1376
1377 #[test]
1378 fn observed_phase_or_pending_defaults_agree_with_allocation_phase_default() {
1379 // The `Pending` sink is load-bearing as the "not yet observed"
1380 // default. `AllocationPhase::default()` returns `Pending`; the
1381 // primitive must return the same variant on the missing-status
1382 // corner. A future default-variant rename that flipped
1383 // `AllocationPhase::default` without flipping the primitive
1384 // (or vice versa) surfaces here as a divergent seed for the
1385 // routing ladder.
1386 let a = alloc_without_status();
1387 assert_eq!(a.observed_phase_or_pending(), AllocationPhase::default());
1388 }
1389
1390 #[test]
1391 fn observed_phase_or_pending_matches_pre_lift_chain_bytewise() {
1392 // The exact pre-lift 5-line chain in
1393 // `tatara-pool-reconciler::allocation_decide::
1394 // AllocationConvergenceCtx::observe` was:
1395 // let phase = alloc
1396 // .status
1397 // .as_ref()
1398 // .map(|s| s.phase)
1399 // .unwrap_or(AllocationPhase::Pending);
1400 // Sweep every corner: (status: None) plus every populated
1401 // status variant. The primitive must be byte-identical for
1402 // every corner so the observer's routing decision matches
1403 // bytewise post-lift.
1404 let none_alloc = alloc_without_status();
1405 assert_eq!(
1406 none_alloc.observed_phase_or_pending(),
1407 none_alloc
1408 .status
1409 .as_ref()
1410 .map(|s| s.phase)
1411 .unwrap_or(AllocationPhase::Pending),
1412 );
1413 for p in AllocationPhase::ALL {
1414 let a = alloc_with_phase(p);
1415 assert_eq!(
1416 a.observed_phase_or_pending(),
1417 a.status
1418 .as_ref()
1419 .map(|s| s.phase)
1420 .unwrap_or(AllocationPhase::Pending),
1421 "primitive must be byte-identical to the pre-lift 5-line chain for {p:?}",
1422 );
1423 }
1424 }
1425
1426 #[test]
1427 fn observed_phase_or_pending_composes_from_observed_phase() {
1428 // The composer sits on top of the borrow-form projection —
1429 // `observed_phase_or_pending() == observed_phase().unwrap_or
1430 // (Pending)`. Pinning the composition means a future
1431 // normalization step layered onto `observed_phase` (a
1432 // generation-filter, a staleness gate, a canonicalization
1433 // pass) reaches BOTH the raw-`Option` accessor and the
1434 // `Pending`-sinked composer through the SAME upstream body,
1435 // without needing a per-corner rewrite of the composer.
1436 let none_alloc = alloc_without_status();
1437 assert_eq!(
1438 none_alloc.observed_phase_or_pending(),
1439 none_alloc
1440 .observed_phase()
1441 .unwrap_or(AllocationPhase::Pending),
1442 );
1443 for p in AllocationPhase::ALL {
1444 let a = alloc_with_phase(p);
1445 assert_eq!(
1446 a.observed_phase_or_pending(),
1447 a.observed_phase().unwrap_or(AllocationPhase::Pending),
1448 "composer must ride on top of the borrow-form projection for {p:?}",
1449 );
1450 }
1451 }
1452
1453 #[test]
1454 fn observed_phase_is_a_pure_projection() {
1455 // Reading the phase twice must not mutate the allocation or
1456 // its status slot — pure projection semantics. Also witnesses
1457 // that the accessor doesn't clone / drop the inner `phase`
1458 // (the `Copy` scalar comes out identical on both reads).
1459 let a = alloc_with_phase(AllocationPhase::Bound);
1460 let one = a.observed_phase();
1461 let two = a.observed_phase();
1462 assert_eq!(one, two);
1463 assert!(a.status.is_some(), "projection must not consume the status");
1464 }
1465
1466 #[test]
1467 fn observed_phase_pending_missing_status_and_populated_pending_collapse_to_same_composer_output(
1468 ) {
1469 // A subtle correctness pin: the missing-`status` corner and
1470 // a populated-with-Pending status BOTH read as `Pending`
1471 // through the composer — the observer cannot distinguish the
1472 // two through this accessor. This matches the pre-lift 5-line
1473 // chain's semantics exactly (an operator patching
1474 // `status.phase: Pending` is indistinguishable from a
1475 // freshly-admitted allocation with no status stamped yet).
1476 // The borrow-form `observed_phase` accessor DOES distinguish
1477 // the two, so a caller that needs to tell them apart reaches
1478 // for the raw `Option`.
1479 let none_alloc = alloc_without_status();
1480 let pending_alloc = alloc_with_phase(AllocationPhase::Pending);
1481
1482 assert_eq!(
1483 none_alloc.observed_phase_or_pending(),
1484 pending_alloc.observed_phase_or_pending(),
1485 );
1486 assert_ne!(
1487 none_alloc.observed_phase(),
1488 pending_alloc.observed_phase(),
1489 "borrow-form accessor MUST distinguish missing-status from populated-Pending",
1490 );
1491 }
1492
1493 #[test]
1494 fn observed_phase_or_pending_missing_status_sink_agrees_with_process_peer_shape() {
1495 // Cross-CRD peer-axis coherence with
1496 // `Process::observed_phase_or_pending`. Both primitives walk
1497 // the identical `.status.as_ref().map(|s| s.phase).unwrap_or
1498 // (<Phase>::Pending)` chain differing ONLY in the `Phase`
1499 // type projected. On a missing-status observation, each
1500 // primitive must return its CRD's `Default`-equivalent
1501 // `Pending` variant — for `EphemeralAllocation` that's
1502 // `AllocationPhase::Pending`; for `Process` that's
1503 // `crate::phase::ProcessPhase::Pending`. This pin binds the
1504 // sink-parity structurally so a future rename of either
1505 // default variant surfaces here as a divergent seed for the
1506 // observer's routing / dispatch decision rather than as
1507 // silent drift between the two reconcilers.
1508 let no_status_alloc = alloc_without_status();
1509 assert_eq!(
1510 no_status_alloc.observed_phase_or_pending(),
1511 AllocationPhase::default(),
1512 );
1513 // Peer-axis invariant on the `Process` side — the primitive
1514 // that owns the same shape reads `ProcessPhase::Pending` on
1515 // the missing-status corner via its own inherent method. The
1516 // parity is coordinated at the `Default` seat: both CRDs'
1517 // phase types default to `Pending`, so a rename that broke
1518 // one without the other would fail one of these two
1519 // conjoined assertions.
1520 assert_eq!(AllocationPhase::default(), AllocationPhase::Pending,);
1521 assert_eq!(
1522 crate::phase::ProcessPhase::default(),
1523 crate::phase::ProcessPhase::Pending,
1524 );
1525 }
1526
1527 // ─── EphemeralAllocation::observed_bound_pool substrate pins ────
1528 //
1529 // The borrow-form status-projection primitive on the bound-pool
1530 // axis. Collapses the pre-lift hand-authored `.status.as_ref()
1531 // .and_then(|s| s.bound_pool.clone())` chain in
1532 // `tatara-pool-reconciler::allocation_decide::
1533 // AllocationConvergenceCtx::observe`'s `bound_pool` seed onto the
1534 // ONE substrate primitive. Cross-CRD peer to
1535 // `Process::observed_identity` on the (CRD × structured-record-
1536 // slot × borrow-form) axis pair — both primitives walk the
1537 // identical `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
1538 // shape. Each pin is fail-before-pass-after: `observed_bound_pool`
1539 // did not exist pre-lift, so any test invoking it fails to compile
1540 // pre-lift and passes post-lift.
1541
1542 fn sample_pool_ref(name: &str, ns: &str) -> AllocationRef {
1543 AllocationRef {
1544 name: name.to_string(),
1545 namespace: ns.to_string(),
1546 }
1547 }
1548
1549 fn alloc_with_bound_pool(bound: Option<AllocationRef>) -> EphemeralAllocation {
1550 let spec = AllocationSpec {
1551 pool_ref: None,
1552 requestor: Requestor {
1553 kind: "manual".into(),
1554 repo: None,
1555 branch: None,
1556 pr_number: None,
1557 sha: None,
1558 pr_labels: vec![],
1559 actor: None,
1560 },
1561 ttl: None,
1562 note: None,
1563 };
1564 let mut a = EphemeralAllocation::new("bp-alloc", spec);
1565 a.status = Some(AllocationStatus {
1566 phase: AllocationPhase::Bound,
1567 bound_pool: bound,
1568 ..AllocationStatus::default()
1569 });
1570 a
1571 }
1572
1573 #[test]
1574 fn observed_bound_pool_returns_none_when_status_is_none() {
1575 // Missing-`status` corner pin: the primitive collapses the
1576 // no-status case to `None` so downstream `.is_some()` /
1577 // `if let Some(_)` / `.cloned().unwrap_or_else(...)` behave
1578 // identically on an `EphemeralAllocation` whose status field
1579 // is `None` and on one whose status carries an unpopulated
1580 // `bound_pool` slot. Matches the pre-lift `.and_then(...)`
1581 // chain's `None` byte-identically at the pool reconciler's
1582 // Release-composition seed.
1583 let a = alloc_without_status();
1584 assert!(a.observed_bound_pool().is_none());
1585 }
1586
1587 #[test]
1588 fn observed_bound_pool_returns_none_when_slot_is_none() {
1589 // Empty-slot-under-populated-status corner pin: the primitive
1590 // returns `None`, matching the missing-`status` corner byte-
1591 // identically. A regression that treated the two corners
1592 // differently would silently promote an internal representation
1593 // detail (whether the pool reconciler has ever written a
1594 // status subresource) into observable behavior at the
1595 // Release-composition branch of the allocation reconciler's
1596 // `decide` transition rule.
1597 let a = alloc_with_bound_pool(None);
1598 assert!(a.observed_bound_pool().is_none());
1599 }
1600
1601 #[test]
1602 fn observed_bound_pool_returns_borrow_when_slot_is_populated() {
1603 // Happy-path pin: with a populated `status.bound_pool` slot,
1604 // the primitive returns a borrowed `&AllocationRef` whose
1605 // (name, namespace) fields match the persisted record. A
1606 // regression that filtered / reshaped / canonicalized the
1607 // record would surface here rather than as silent skew at the
1608 // Release-composition seed's `.cloned()` materialization.
1609 let expected = sample_pool_ref("demo-pool", "pools");
1610 let a = alloc_with_bound_pool(Some(expected.clone()));
1611 let observed = a.observed_bound_pool().expect("populated slot");
1612 assert_eq!(observed, &expected);
1613 assert_eq!(observed.name, "demo-pool");
1614 assert_eq!(observed.namespace, "pools");
1615 }
1616
1617 #[test]
1618 fn observed_bound_pool_is_a_zero_copy_borrow_projection() {
1619 // Borrow-discipline pin: the returned reference points at the
1620 // persisted `AllocationRef` in place — NOT a fresh allocation
1621 // or a clone. A regression that switched the projection to an
1622 // owned `AllocationRef` (via `.clone()`) would defeat the
1623 // zero-copy contract the lift's primary strict-widening
1624 // delivers (the observer's Release-composition arm clones
1625 // once at the composition point where the
1626 // `AllocationConvergenceCtx` snapshot slot requires the owned
1627 // value). Peer to the sibling
1628 // `Process::observed_identity_is_a_zero_copy_borrow_projection`
1629 // pin on the `Process` CRD's `status.identity` slot.
1630 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1631 let observed = a.observed_bound_pool().expect("populated slot") as *const _;
1632 let persisted = a.status.as_ref().unwrap().bound_pool.as_ref().unwrap() as *const _;
1633 assert!(std::ptr::eq(observed, persisted));
1634 }
1635
1636 #[test]
1637 fn observed_bound_pool_is_a_pure_projection() {
1638 // Purity pin: calling the projection twice on the same
1639 // `EphemeralAllocation` returns byte-identical borrows (same
1640 // pointer). A regression that introduced state — a lazy-
1641 // cached reference, a normalization step that ran once and
1642 // cached — would surface here rather than as silent drift
1643 // between two dispatches within one reconcile pass.
1644 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1645 let one = a.observed_bound_pool().expect("populated slot") as *const _;
1646 let two = a.observed_bound_pool().expect("populated slot") as *const _;
1647 assert!(std::ptr::eq(one, two));
1648 }
1649
1650 #[test]
1651 fn observed_bound_pool_matches_pre_lift_chain_bytewise() {
1652 // Byte-identical parity pin between the borrow-form primitive
1653 // here and the pre-lift `tatara-pool-reconciler`
1654 // `.status.as_ref().and_then(|s| s.bound_pool.clone())` chain.
1655 // Sweeps every corner every callsite plausibly encounters
1656 // (missing status, empty `bound_pool` slot, populated
1657 // `bound_pool` slot). A regression that inserted a
1658 // normalization step at the primitive the pre-lift chain does
1659 // NOT apply — or vice versa — surfaces here rather than as
1660 // silent drift between the pre-lift consumer site and the ONE
1661 // substrate owner it now routes through.
1662 fn pre_lift(a: &EphemeralAllocation) -> Option<AllocationRef> {
1663 a.status.as_ref().and_then(|s| s.bound_pool.clone())
1664 }
1665 // Missing status.
1666 let a = alloc_without_status();
1667 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1668 // Populated status, empty `bound_pool` slot.
1669 let a = alloc_with_bound_pool(None);
1670 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1671 // Populated status, populated `bound_pool` slot.
1672 let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1673 assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1674 }
1675
1676 #[test]
1677 fn observed_bound_pool_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
1678 // Cross-corner coherence pin: the missing-`status` corner and
1679 // the populated-empty-slot corner return `Option`s whose
1680 // `.is_none()` / `.is_some()` observations are IDENTICAL. A
1681 // regression that promoted the missing-`status` corner to a
1682 // typed error (via a signature change to `Result<_, _>`) — or
1683 // that widened the empty-slot corner to a synthetic
1684 // `Some(AllocationRef::default())` — would surface here rather
1685 // than as silent operator-facing divergence between a never-
1686 // status-written allocation and a bound-pool-cleared
1687 // allocation on the Release-composition branch.
1688 let a_no_status = alloc_without_status();
1689 let a_empty_slot = alloc_with_bound_pool(None);
1690 assert_eq!(
1691 a_no_status.observed_bound_pool().is_none(),
1692 a_empty_slot.observed_bound_pool().is_none(),
1693 );
1694 assert_eq!(
1695 a_no_status.observed_bound_pool().is_some(),
1696 a_empty_slot.observed_bound_pool().is_some(),
1697 );
1698 }
1699
1700 #[test]
1701 fn observed_bound_pool_shape_agrees_with_process_observed_identity_peer_axis() {
1702 // Cross-CRD peer-axis coherence pin binding the SAME
1703 // `.status.as_ref().and_then(|s| s.<slot>.as_ref())` shape
1704 // that both `EphemeralAllocation::observed_bound_pool` (this
1705 // primitive) and `Process::observed_identity` walk, differing
1706 // ONLY in the record projected. Structural test — both
1707 // signatures must resolve as `&Self -> Option<&Record>` fn
1708 // pointers, so a future rename or a signature drift that
1709 // (say) widened one side to `Option<Record>` or narrowed one
1710 // side to `Option<&str>` fails to compile here rather than
1711 // silently drifting the two reconcilers apart at their
1712 // respective observer seeds. The runtime side of the pin
1713 // sweeps the missing-status + empty-slot corners on the
1714 // `EphemeralAllocation` half; the `Process` half is exercised
1715 // by its own `crd.rs::tests::observed_identity_*` pin
1716 // family — this test binds only the peer-axis shape.
1717 let a_no_status = alloc_without_status();
1718 let a_empty_slot = alloc_with_bound_pool(None);
1719 assert!(a_no_status.observed_bound_pool().is_none());
1720 assert!(a_empty_slot.observed_bound_pool().is_none());
1721 // Structural peer-axis coherence: bind both signatures as fn
1722 // pointers at their peer resolution type so the compiler
1723 // refuses to build if either side's shape drifts. The `_`
1724 // let-bindings assert the target type inference.
1725 let _bound_pool_shape: fn(&EphemeralAllocation) -> Option<&AllocationRef> =
1726 EphemeralAllocation::observed_bound_pool;
1727 let _identity_shape: fn(&crate::prelude::Process) -> Option<&crate::identity::Identity> =
1728 crate::prelude::Process::observed_identity;
1729 }
1730
1731 // ─── EphemeralAllocation::observed_expires_at substrate pins ────
1732 //
1733 // The copy-form status-projection primitive on the TTL-expiry axis.
1734 // Collapses the pre-lift hand-authored `.status.as_ref().and_then(
1735 // |s| s.expires_at)` chain in `tatara-pool-reconciler::
1736 // allocation_decide::AllocationConvergenceCtx::observe`'s
1737 // `expires_at` seed onto the ONE substrate primitive. Same-CRD peer
1738 // to `observed_phase` on the (copy-form × status-slot) axis — both
1739 // primitives walk the identical `.status.as_ref().<map|and_then>(
1740 // |s| s.<Copy-field>)` shape. Each pin is fail-before-pass-after:
1741 // `observed_expires_at` did not exist pre-lift, so any test invoking
1742 // it fails to compile pre-lift and passes post-lift.
1743
1744 fn alloc_with_expires_at(expires_at: Option<DateTime<Utc>>) -> EphemeralAllocation {
1745 let spec = AllocationSpec {
1746 pool_ref: None,
1747 requestor: Requestor {
1748 kind: "manual".into(),
1749 repo: None,
1750 branch: None,
1751 pr_number: None,
1752 sha: None,
1753 pr_labels: vec![],
1754 actor: None,
1755 },
1756 ttl: None,
1757 note: None,
1758 };
1759 let mut a = EphemeralAllocation::new("exp-alloc", spec);
1760 a.status = Some(AllocationStatus {
1761 phase: AllocationPhase::Bound,
1762 expires_at,
1763 ..AllocationStatus::default()
1764 });
1765 a
1766 }
1767
1768 #[test]
1769 fn observed_expires_at_returns_none_when_status_is_none() {
1770 // Missing-`status` corner pin: the primitive collapses the
1771 // no-status case to `None` so downstream `.is_some()` / any
1772 // deadline comparison behaves identically on an
1773 // `EphemeralAllocation` whose status field is `None` and on
1774 // one whose status carries an unpopulated `expires_at` slot.
1775 // Matches the pre-lift `.and_then(...)` chain's `None` byte-
1776 // identically at the pool reconciler's Release-composition
1777 // TTL gate.
1778 let a = alloc_without_status();
1779 assert!(a.observed_expires_at().is_none());
1780 }
1781
1782 #[test]
1783 fn observed_expires_at_returns_none_when_slot_is_none() {
1784 // Empty-slot-under-populated-status corner pin: the primitive
1785 // returns `None`, matching the missing-`status` corner byte-
1786 // identically. A regression that treated the two corners
1787 // differently would silently promote an internal representation
1788 // detail (whether the pool reconciler has ever written a
1789 // `status.expires_at` field for a not-yet-Bound allocation)
1790 // into observable behavior at the Release-composition branch
1791 // of the allocation reconciler's `decide` transition rule.
1792 let a = alloc_with_expires_at(None);
1793 assert!(a.observed_expires_at().is_none());
1794 }
1795
1796 #[test]
1797 fn observed_expires_at_returns_populated_timestamp_verbatim() {
1798 // Happy-path pin: with a populated `status.expires_at` slot,
1799 // the primitive returns the persisted `DateTime<Utc>` verbatim.
1800 // A regression that filtered / clamped / canonicalized the
1801 // timestamp would surface here rather than as silent skew at
1802 // the Release-composition TTL gate's `>=` deadline comparison.
1803 let expected = Utc::now();
1804 let a = alloc_with_expires_at(Some(expected));
1805 assert_eq!(a.observed_expires_at(), Some(expected));
1806 }
1807
1808 #[test]
1809 fn observed_expires_at_is_a_pure_projection() {
1810 // Purity pin: calling the projection twice on the same
1811 // `EphemeralAllocation` returns byte-identical `Option`s. A
1812 // regression that introduced state — a lazy-cached value, a
1813 // normalization step that ran once and cached — would surface
1814 // here rather than as silent drift between two dispatches
1815 // within one reconcile pass.
1816 let expected = Utc::now();
1817 let a = alloc_with_expires_at(Some(expected));
1818 assert_eq!(a.observed_expires_at(), a.observed_expires_at());
1819 }
1820
1821 #[test]
1822 fn observed_expires_at_matches_pre_lift_chain_bytewise() {
1823 // Byte-identical parity pin between the copy-form primitive
1824 // here and the pre-lift `tatara-pool-reconciler`
1825 // `.status.as_ref().and_then(|s| s.expires_at)` chain. Sweeps
1826 // every corner every callsite plausibly encounters (missing
1827 // status, empty `expires_at` slot, populated `expires_at`
1828 // slot). A regression that inserted a normalization step at
1829 // the primitive the pre-lift chain does NOT apply — or vice
1830 // versa — surfaces here rather than as silent drift between
1831 // the pre-lift consumer site and the ONE substrate owner it
1832 // now routes through.
1833 fn pre_lift(a: &EphemeralAllocation) -> Option<DateTime<Utc>> {
1834 a.status.as_ref().and_then(|s| s.expires_at)
1835 }
1836 // Missing status.
1837 let a = alloc_without_status();
1838 assert_eq!(a.observed_expires_at(), pre_lift(&a));
1839 // Populated status, empty `expires_at` slot.
1840 let a = alloc_with_expires_at(None);
1841 assert_eq!(a.observed_expires_at(), pre_lift(&a));
1842 // Populated status, populated `expires_at` slot.
1843 let a = alloc_with_expires_at(Some(Utc::now()));
1844 assert_eq!(a.observed_expires_at(), pre_lift(&a));
1845 }
1846
1847 #[test]
1848 fn observed_expires_at_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
1849 // Cross-corner coherence pin: the missing-`status` corner and
1850 // the populated-empty-slot corner return `Option`s whose
1851 // `.is_none()` / `.is_some()` observations are IDENTICAL. A
1852 // regression that promoted the missing-`status` corner to a
1853 // typed error (via a signature change to `Result<_, _>`) — or
1854 // that widened the empty-slot corner to a synthetic
1855 // `Some(Utc::now())` — would surface here rather than as
1856 // silent operator-facing divergence between a never-status-
1857 // written allocation and a Bind-time-without-TTL allocation on
1858 // the Release-composition branch.
1859 let a_no_status = alloc_without_status();
1860 let a_empty_slot = alloc_with_expires_at(None);
1861 assert_eq!(
1862 a_no_status.observed_expires_at().is_none(),
1863 a_empty_slot.observed_expires_at().is_none(),
1864 );
1865 assert_eq!(
1866 a_no_status.observed_expires_at().is_some(),
1867 a_empty_slot.observed_expires_at().is_some(),
1868 );
1869 }
1870
1871 #[test]
1872 fn observed_expires_at_shape_agrees_with_observed_phase_peer_axis() {
1873 // Same-CRD peer-axis coherence pin binding the SAME
1874 // `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape
1875 // that both `EphemeralAllocation::observed_expires_at` (this
1876 // primitive) and `EphemeralAllocation::observed_phase` walk,
1877 // differing only in the outer combinator (`and_then` here
1878 // because the persisted field is itself `Option<T>`, `map`
1879 // there because the persisted phase is bare) and in the
1880 // projected `Copy` type. Structural test — both signatures
1881 // must resolve as `&Self -> Option<T>` fn pointers with `T`
1882 // `Copy`, so a future rename or a signature drift that (say)
1883 // widened one side to `Option<&T>` or narrowed one side to
1884 // `T` fails to compile here rather than silently drifting
1885 // the family apart. The runtime side of the pin sweeps the
1886 // missing-status + empty-slot corners on the `expires_at`
1887 // half; the `phase` half is exercised by its own
1888 // `tests::observed_phase_*` pin family — this test binds
1889 // only the peer-axis shape.
1890 let a_no_status = alloc_without_status();
1891 let a_empty_slot = alloc_with_expires_at(None);
1892 assert!(a_no_status.observed_expires_at().is_none());
1893 assert!(a_empty_slot.observed_expires_at().is_none());
1894 // Structural peer-axis coherence: bind both signatures as fn
1895 // pointers at their peer resolution type so the compiler
1896 // refuses to build if either side's shape drifts.
1897 let _expires_at_shape: fn(&EphemeralAllocation) -> Option<DateTime<Utc>> =
1898 EphemeralAllocation::observed_expires_at;
1899 let _phase_shape: fn(&EphemeralAllocation) -> Option<AllocationPhase> =
1900 EphemeralAllocation::observed_phase;
1901 }
1902
1903 #[test]
1904 fn allocation_spec_omits_optional_fields() {
1905 let s = AllocationSpec {
1906 pool_ref: None,
1907 requestor: Requestor {
1908 kind: "manual".into(),
1909 repo: None,
1910 branch: None,
1911 pr_number: None,
1912 sha: None,
1913 pr_labels: vec![],
1914 actor: None,
1915 },
1916 ttl: None,
1917 note: None,
1918 };
1919 let yaml = serde_yaml::to_string(&s).unwrap();
1920 assert!(!yaml.contains("poolRef"));
1921 assert!(!yaml.contains("ttl"));
1922 assert!(!yaml.contains("note"));
1923 }
1924
1925 // ─── AllocationStatus::transition substrate pins ────────────────────
1926 //
1927 // Pin the substrate composer at fail-before-pass-after granularity:
1928 // the composer did not exist pre-lift, so any regression against
1929 // the four hand-authored sites in
1930 // `tatara-pool-reconciler::controller_allocation::reconcile_inner`
1931 // surfaces at these pins rather than as silent operator-visible
1932 // status-patch skew.
1933
1934 fn anchor_time() -> DateTime<Utc> {
1935 // A deterministic non-`Utc::now()` anchor so pins that read
1936 // back `phase_since` do not race the wall clock.
1937 DateTime::parse_from_rfc3339("2026-05-01T00:00:00Z")
1938 .unwrap()
1939 .with_timezone(&Utc)
1940 }
1941
1942 #[test]
1943 fn allocation_status_transition_stamps_supplied_phase_verbatim() {
1944 for phase in AllocationPhase::ALL {
1945 let s = AllocationStatus::transition(phase, "irrelevant", anchor_time());
1946 assert_eq!(s.phase, phase, "phase drifted for {phase:?}");
1947 }
1948 }
1949
1950 #[test]
1951 fn allocation_status_transition_stamps_supplied_message_verbatim() {
1952 let s = AllocationStatus::transition(
1953 AllocationPhase::Queued,
1954 "pool matched; no Free member available",
1955 anchor_time(),
1956 );
1957 assert_eq!(
1958 s.message.as_deref(),
1959 Some("pool matched; no Free member available"),
1960 );
1961 }
1962
1963 #[test]
1964 fn allocation_status_transition_sets_phase_since_to_supplied_now() {
1965 let anchor = anchor_time();
1966 let s = AllocationStatus::transition(AllocationPhase::Bound, "bound", anchor);
1967 assert_eq!(
1968 s.phase_since,
1969 Some(anchor),
1970 "phase_since must be the supplied `now`, not a fresh Utc::now()",
1971 );
1972 }
1973
1974 #[test]
1975 fn allocation_status_transition_defaults_every_optional_slot() {
1976 // The composer stamps only the three always-present slots
1977 // (`phase + phase_since + message`); every other slot on
1978 // `AllocationStatus` must land at its `Default`-equivalent
1979 // variant so a caller-branch that attaches an optional slot
1980 // via struct-update syntax does not silently inherit a
1981 // pre-populated non-`None`/non-empty value.
1982 let s = AllocationStatus::transition(AllocationPhase::Released, "released", anchor_time());
1983 assert!(s.bound_pool.is_none(), "bound_pool must default to None");
1984 assert!(
1985 s.assigned_process.is_none(),
1986 "assigned_process must default to None"
1987 );
1988 assert!(
1989 s.allocated_at.is_none(),
1990 "allocated_at must default to None"
1991 );
1992 assert!(s.expires_at.is_none(), "expires_at must default to None");
1993 assert!(
1994 s.conditions.is_empty(),
1995 "conditions must default to an empty Vec"
1996 );
1997 }
1998
1999 #[test]
2000 fn allocation_status_transition_accepts_owned_string_and_static_str() {
2001 // `impl Into<String>` matches every current callsite:
2002 // three of the four hand-authored sites pass `&'static str`
2003 // literal reasons; the fourth ("bound to pool member") also
2004 // passes a `&'static str`. Sibling to
2005 // `tatara-reconciler::patch::phase_status_msg`'s identical
2006 // `impl Into<String>` signature.
2007 let via_static = AllocationStatus::transition(
2008 AllocationPhase::NoMatchingPool,
2009 "no Pool selector matched this Requestor",
2010 anchor_time(),
2011 );
2012 let via_owned = AllocationStatus::transition(
2013 AllocationPhase::NoMatchingPool,
2014 String::from("no Pool selector matched this Requestor"),
2015 anchor_time(),
2016 );
2017 assert_eq!(via_static.message, via_owned.message);
2018 }
2019
2020 #[test]
2021 fn allocation_status_transition_serializes_to_pre_lift_json_shape() {
2022 // Byte-shape pin against the exact `json!({ "status": {
2023 // "phase": <variant>, "phaseSince": <now>, "message": "<msg>"
2024 // } })` incantation every pre-lift callsite restated. A
2025 // regression that reordered a slot, dropped the `phaseSince`
2026 // stamp, or drifted the camelCase key naming here surfaces at
2027 // THIS pin rather than as a subtle patch_status body the K8s
2028 // API server accepts but the pool reconciler's next observe
2029 // pass fails to read back.
2030 let anchor = anchor_time();
2031 let via_composer =
2032 AllocationStatus::transition(AllocationPhase::NoMatchingPool, "no match", anchor);
2033 let composed = serde_json::json!({ "status": via_composer });
2034 let hand_authored = serde_json::json!({
2035 "status": {
2036 "phase": AllocationPhase::NoMatchingPool,
2037 "phaseSince": anchor,
2038 "message": "no match",
2039 }
2040 });
2041 assert_eq!(composed, hand_authored);
2042 }
2043
2044 #[test]
2045 fn allocation_status_transition_composes_with_struct_update_for_bind_seed() {
2046 // Pin the compound shape the `AllocationDecision::Bind`
2047 // callsite composes: the substrate seed carries `phase +
2048 // phase_since + message`, and the branch attaches
2049 // `bound_pool` + `assigned_process` + `allocated_at` +
2050 // `expires_at` via struct-update syntax. Post-lift the four
2051 // extra slots survive the compose intact and the base three
2052 // slots inherit the composer's stamps verbatim.
2053 let anchor = anchor_time();
2054 let ttl = anchor + chrono::Duration::hours(1);
2055 let pool = AllocationRef::new("demo-pool", "pools");
2056 let assigned = AllocationRef::new("demo-abcd", "pools");
2057 let bind_status = AllocationStatus {
2058 bound_pool: Some(pool.clone()),
2059 assigned_process: Some(assigned.clone()),
2060 allocated_at: Some(anchor),
2061 expires_at: Some(ttl),
2062 ..AllocationStatus::transition(AllocationPhase::Bound, "bound to pool member", anchor)
2063 };
2064 // Base-three slots stamped by the composer.
2065 assert_eq!(bind_status.phase, AllocationPhase::Bound);
2066 assert_eq!(bind_status.phase_since, Some(anchor));
2067 assert_eq!(bind_status.message.as_deref(), Some("bound to pool member"));
2068 // Struct-update-attached branch slots.
2069 assert_eq!(
2070 bind_status.bound_pool.as_ref().map(|r| &r.name),
2071 Some(&pool.name)
2072 );
2073 assert_eq!(
2074 bind_status.assigned_process.as_ref().map(|r| &r.name),
2075 Some(&assigned.name)
2076 );
2077 assert_eq!(bind_status.allocated_at, Some(anchor));
2078 assert_eq!(bind_status.expires_at, Some(ttl));
2079 }
2080
2081 // ─── AllocationStatus::bound_transition substrate pins ─────────────
2082 //
2083 // Pin the compound composer at fail-before-pass-after granularity:
2084 // the composer wraps [`AllocationStatus::transition`] with the
2085 // `bound_pool + assigned_process` pair the Bind / Release arms
2086 // both stamped inline pre-lift.
2087
2088 #[test]
2089 fn allocation_status_bound_transition_stamps_supplied_pool_and_process_verbatim() {
2090 let anchor = anchor_time();
2091 let pool = AllocationRef::new("demo-pool", "pools");
2092 let assigned = AllocationRef::new("demo-abcd", "pools");
2093 let s = AllocationStatus::bound_transition(
2094 AllocationPhase::Released,
2095 "released; pool reconciler will return the member",
2096 anchor,
2097 pool.clone(),
2098 assigned.clone(),
2099 );
2100 assert_eq!(s.bound_pool.as_ref(), Some(&pool));
2101 assert_eq!(s.assigned_process.as_ref(), Some(&assigned));
2102 }
2103
2104 #[test]
2105 fn allocation_status_bound_transition_inherits_transition_triplet_verbatim() {
2106 // The compound composer must not stamp its own `phase +
2107 // phase_since + message` triplet — it MUST compose the pair
2108 // atop the substrate `Self::transition` seed so any future
2109 // evolution to the base triplet lands at ONE site and this
2110 // composer inherits the upgrade mechanically. Pin the triplet
2111 // through the same axis-uniform reads the transition tests use.
2112 let anchor = anchor_time();
2113 let via_compound = AllocationStatus::bound_transition(
2114 AllocationPhase::Bound,
2115 "bound to pool member",
2116 anchor,
2117 AllocationRef::new("p", "ns"),
2118 AllocationRef::new("q", "ns"),
2119 );
2120 let via_base =
2121 AllocationStatus::transition(AllocationPhase::Bound, "bound to pool member", anchor);
2122 assert_eq!(via_compound.phase, via_base.phase);
2123 assert_eq!(via_compound.phase_since, via_base.phase_since);
2124 assert_eq!(via_compound.message, via_base.message);
2125 }
2126
2127 #[test]
2128 fn allocation_status_bound_transition_defaults_every_optional_slot_beyond_the_pair() {
2129 // The compound composer stamps only the base triplet + the
2130 // `bound_pool + assigned_process` pair; every other optional
2131 // slot (`allocated_at` / `expires_at` / `conditions`) must
2132 // land at its `Default`-equivalent variant so a caller-branch
2133 // that attaches an addendum via struct-update syntax (a Bind
2134 // arm's `allocated_at` + `expires_at` stamp) does not
2135 // silently inherit a pre-populated non-`None`/non-empty value.
2136 let s = AllocationStatus::bound_transition(
2137 AllocationPhase::Released,
2138 "released",
2139 anchor_time(),
2140 AllocationRef::new("p", "ns"),
2141 AllocationRef::new("q", "ns"),
2142 );
2143 assert!(
2144 s.allocated_at.is_none(),
2145 "allocated_at must default to None"
2146 );
2147 assert!(s.expires_at.is_none(), "expires_at must default to None");
2148 assert!(
2149 s.conditions.is_empty(),
2150 "conditions must default to an empty Vec"
2151 );
2152 }
2153
2154 #[test]
2155 fn allocation_status_bound_transition_composes_with_struct_update_for_bind_seed() {
2156 // Pin the compound shape the `AllocationDecision::Bind`
2157 // callsite post-lift composes: the compound composer seeds
2158 // `phase + phase_since + message + bound_pool +
2159 // assigned_process`, and the Bind branch attaches
2160 // `allocated_at` + `expires_at` via struct-update syntax.
2161 // Post-lift the two extra slots survive the compose intact
2162 // and the base five slots inherit the composer's stamps
2163 // verbatim.
2164 let anchor = anchor_time();
2165 let ttl = anchor + chrono::Duration::hours(1);
2166 let pool = AllocationRef::new("demo-pool", "pools");
2167 let assigned = AllocationRef::new("demo-abcd", "pools");
2168 let bind_status = AllocationStatus {
2169 allocated_at: Some(anchor),
2170 expires_at: Some(ttl),
2171 ..AllocationStatus::bound_transition(
2172 AllocationPhase::Bound,
2173 "bound to pool member",
2174 anchor,
2175 pool.clone(),
2176 assigned.clone(),
2177 )
2178 };
2179 assert_eq!(bind_status.phase, AllocationPhase::Bound);
2180 assert_eq!(bind_status.phase_since, Some(anchor));
2181 assert_eq!(bind_status.message.as_deref(), Some("bound to pool member"));
2182 assert_eq!(bind_status.bound_pool.as_ref(), Some(&pool));
2183 assert_eq!(bind_status.assigned_process.as_ref(), Some(&assigned));
2184 assert_eq!(bind_status.allocated_at, Some(anchor));
2185 assert_eq!(bind_status.expires_at, Some(ttl));
2186 }
2187
2188 #[test]
2189 fn allocation_status_bound_transition_matches_pre_lift_release_arm_verbatim() {
2190 // Byte-shape pin against the exact pre-lift `AllocationStatus
2191 // { bound_pool: Some(pool), assigned_process:
2192 // Some(AllocationRef::new(..)), ..AllocationStatus::transition
2193 // (Released, "…", now) }` composition the
2194 // `AllocationDecision::Release` arm restated inline pre-lift.
2195 // A regression that reordered the pair, dropped a `Some`, or
2196 // drifted the composed base triplet here surfaces at THIS pin
2197 // rather than as a subtle patch_status body the K8s API
2198 // server accepts but the audit record disagrees on.
2199 let anchor = anchor_time();
2200 let pool = AllocationRef::new("demo-pool", "pools");
2201 let assigned = AllocationRef::new("demo-abcd", "pools");
2202 let via_composer = AllocationStatus::bound_transition(
2203 AllocationPhase::Released,
2204 "released; pool reconciler will return the member",
2205 anchor,
2206 pool.clone(),
2207 assigned.clone(),
2208 );
2209 let via_hand_authored = AllocationStatus {
2210 bound_pool: Some(pool),
2211 assigned_process: Some(assigned),
2212 ..AllocationStatus::transition(
2213 AllocationPhase::Released,
2214 "released; pool reconciler will return the member",
2215 anchor,
2216 )
2217 };
2218 assert_eq!(
2219 serde_json::to_value(&via_composer).unwrap(),
2220 serde_json::to_value(&via_hand_authored).unwrap(),
2221 );
2222 }
2223
2224 #[test]
2225 fn allocation_status_transition_shape_agrees_with_pool_status_observed_peer() {
2226 // Cross-CRD peer-axis coherence: both substrate composers
2227 // (`PoolStatus::observed`, `AllocationStatus::transition`)
2228 // accept a caller-supplied `now: DateTime<Utc>` at the SAME
2229 // signature slot, stamp it into `phase_since` uniformly, and
2230 // leave every other slot at its `Default`-equivalent variant.
2231 // Structural pin: if either side's `now` signature drifts to
2232 // `impl Into<DateTime<Utc>>` or a reference form, this bind
2233 // fails to compile here rather than silently drifting the
2234 // family apart.
2235 let _allocation_shape: fn(
2236 AllocationPhase,
2237 &'static str,
2238 DateTime<Utc>,
2239 ) -> AllocationStatus = AllocationStatus::transition;
2240 // (`PoolStatus::observed`'s pinned coherence lives at its own
2241 // peer pin family in `crate::pool`; this pin binds the
2242 // `AllocationStatus::transition` side of the peer pair.)
2243 }
2244}