Skip to main content

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}