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    #[serde(default)]
332    pub conditions: Vec<AllocationCondition>,
333}
334
335/// Allocation lifecycle phase.
336///
337/// Sibling closed-set lifts on the same `EphemeralAllocation` /
338/// `EphemeralPool` axis: [`crate::pool::ReplacementPolicy::ALL`],
339/// [`crate::pool::ReturnPolicy::ALL`]. Sibling closed-sets on the
340/// `tatara-process` algebra: [`crate::lifetime::TeardownPolicy::ALL`],
341/// [`crate::lifetime::LifetimeKind::ALL`],
342/// [`crate::boundary::ConditionKind::ALL`],
343/// [`crate::intent::IntentKind::ALL`],
344/// [`crate::phase::ProcessPhase::ALL`],
345/// [`crate::signal::ProcessSignal::ALL`].
346#[derive(
347    Clone,
348    Copy,
349    Debug,
350    PartialEq,
351    Eq,
352    Hash,
353    Serialize,
354    Deserialize,
355    JsonSchema,
356    tatara_closed_set::DeriveClosedSet,
357)]
358#[serde(rename_all = "PascalCase")]
359#[closed_set(via = "as_str", generate_unknown, display)]
360pub enum AllocationPhase {
361    /// Admitted; pool selector matching not yet attempted.
362    Pending,
363    /// Routed to a pool but no `Free` member is available — queued.
364    Queued,
365    /// A pool member has been assigned + transitioned to Allocated.
366    Bound,
367    /// `expires_at` reached or requestor deleted; member is returning.
368    Releasing,
369    /// Released; the allocation is a permanent audit record.
370    Released,
371    /// No pool selector matched. The reconciler will retry on each
372    /// pool spec update; surfaced in status so operators see why.
373    NoMatchingPool,
374    /// Pool refused (e.g., `max_size` reached and no member can be
375    /// freed) — operator intervention needed.
376    Failed,
377}
378
379impl Default for AllocationPhase {
380    fn default() -> Self {
381        Self::Pending
382    }
383}
384
385impl AllocationPhase {
386    /// The closed set of allocation phases — single source of truth
387    /// that drives the `as_str` / Display / `FromStr` triad AND the
388    /// `is_terminal` / `needs_pool_routing` predicate pair the
389    /// allocation reconciler's observe/decide split dispatches on.
390    /// Adding an eighth variant lands at one `ALL` entry + one
391    /// `as_str` arm + one arm per predicate — exhaustively checked by
392    /// the compiler (the `[Self; 7]` array literal forces the arity)
393    /// and by the implication test
394    /// (`allocation_phase_terminal_excludes_routing`) so a new
395    /// variant can't claim to be both terminal AND routing-eligible.
396    pub const ALL: [Self; 7] = [
397        Self::Pending,
398        Self::Queued,
399        Self::Bound,
400        Self::Releasing,
401        Self::Released,
402        Self::NoMatchingPool,
403        Self::Failed,
404    ];
405
406    /// Canonical PascalCase wire-format projection — matches the
407    /// serde `rename_all = "PascalCase"` output verbatim AND the CRD
408    /// `enum:` enumeration the allocation reconciler stamps on the
409    /// `ephemeralallocations.tatara.pleme.io` schema. Pinned by
410    /// `allocation_phase_as_str_matches_serde` so a variant rename
411    /// can't drift between the typed surface, the CRD enum, the YAML
412    /// wire format AND any operator-facing diagnostic composed via
413    /// Display rather than a hard-coded literal that would silently
414    /// rot.
415    pub const fn as_str(self) -> &'static str {
416        match self {
417            Self::Pending => "Pending",
418            Self::Queued => "Queued",
419            Self::Bound => "Bound",
420            Self::Releasing => "Releasing",
421            Self::Released => "Released",
422            Self::NoMatchingPool => "NoMatchingPool",
423            Self::Failed => "Failed",
424        }
425    }
426
427    /// True iff the allocation has reached an absorbing state —
428    /// `Released` (clean audit record) or `Failed` (pool refused;
429    /// operator intervention needed). The allocation reconciler
430    /// short-circuits both phases to `NoOp` rather than re-running
431    /// the routing / heartbeat ladder against a settled record.
432    ///
433    /// Closed-set match (not `matches!`) so a future variant
434    /// triggers the compiler's exhaustiveness check at this site
435    /// rather than silently defaulting to `false` and letting a new
436    /// terminal phase fall through into pool rebinding. Paired with
437    /// `needs_pool_routing` they form the two-axis projection
438    /// `allocation_decide::AllocationConvergence::decide` matches
439    /// against — the impossible bucket `(true, true)` is pinned
440    /// empty by `allocation_phase_terminal_excludes_routing`.
441    pub const fn is_terminal(self) -> bool {
442        match self {
443            Self::Released | Self::Failed => true,
444            Self::Pending | Self::Queued | Self::Bound | Self::Releasing | Self::NoMatchingPool => {
445                false
446            }
447        }
448    }
449
450    /// True iff the allocation is on the routing path — the
451    /// reconciler still needs to resolve a target pool + look up a
452    /// free member. `Pending` (just admitted), `Queued` (matched
453    /// pool was full last tick), and `NoMatchingPool` (no selector
454    /// matched yet; retry on pool spec updates) all live here. The
455    /// settled non-terminal phases `Bound` (already matched) and
456    /// `Releasing` (being torn down) don't — they short-circuit to
457    /// the heartbeat / release ladder without re-resolving the pool.
458    ///
459    /// Closed-set match (not `matches!`) — same exhaustiveness
460    /// discipline as [`Self::is_terminal`]. Lifts the open-coded
461    /// `phase != Released && phase != Bound` gate that
462    /// `allocation_decide::AllocationConvergenceCtx::observe` used
463    /// to predicate pool resolution on, AND closes the latent gap
464    /// where `Failed` / `Releasing` (neither `Released` nor `Bound`)
465    /// would slip through to the routing branch — a `Failed`
466    /// allocation without a deletion timestamp could be silently
467    /// rebound to a fresh pool member, which is the opposite of
468    /// "operator intervention needed."
469    pub const fn needs_pool_routing(self) -> bool {
470        match self {
471            Self::Pending | Self::Queued | Self::NoMatchingPool => true,
472            Self::Bound | Self::Releasing | Self::Released | Self::Failed => false,
473        }
474    }
475}
476
477// `impl FromStr for AllocationPhase` + `impl tatara_lisp::ClosedSet for
478// AllocationPhase` + `impl std::fmt::Display for AllocationPhase` are
479// generated by `#[derive(tatara_closed_set::DeriveClosedSet)]` on the enum
480// declaration above. `label` delegates to the inherent
481// `AllocationPhase::as_str` via `#[closed_set(via = "as_str")]` so the
482// PascalCase wire-format projection stays load-bearing (matches the serde
483// rename + the CRD `enum:` enumeration the allocation reconciler stamps
484// on the `ephemeralallocations.tatara.pleme.io` schema verbatim) while
485// generic `T: ClosedSet` consumers reach the STABLE workspace-wide name
486// (`label`). The `display` flag emits the `f.write_str(self.as_str())`
487// delegation block at the same proc-macro site rather than a
488// hand-rolled `fmt::Display` block per implementor.
489
490// `pub struct UnknownAllocationPhase(pub String)` is generated by
491// `#[derive(tatara_closed_set::DeriveClosedSet)]` + `#[closed_set(generate_unknown)]`
492// on the enum declaration above. The auto-derived label `"allocation phase"`
493// matches the prior hand-rolled `#[error("unknown allocation phase: {0}")]`
494// verbatim — pinned generically by clause (5) of
495// `tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>()` (called
496// from `allocation_phase_is_well_formed_closed_set` in the test module).
497// Symmetric to [`crate::pool::UnknownReplacementPolicy`],
498// [`crate::pool::UnknownReturnPolicy`],
499// [`crate::lifetime::UnknownTeardownPolicy`],
500// [`crate::boundary::UnknownConditionKind`], and
501// [`crate::phase::UnknownPhase`].
502
503/// Allocation Condition (same shape as PoolCondition for downstream
504/// uniformity).
505#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
506#[serde(rename_all = "camelCase")]
507pub struct AllocationCondition {
508    pub type_: String,
509    pub status: String,
510    pub reason: String,
511    pub message: String,
512    pub last_transition_time: DateTime<Utc>,
513}
514
515impl EphemeralAllocation {
516    /// The copy-form status-projection primitive on the phase axis:
517    /// returns the [`AllocationPhase`] the pool reconciler currently
518    /// persists at `status.phase`, wrapped in an `Option` so the
519    /// missing-`status` corner collapses to `None` — the ONE-liner
520    /// collapse of the paired `self.status.as_ref().map(|s| s.phase)`
521    /// incantation the pool reconciler's `AllocationConvergenceCtx::
522    /// observe` restated by hand pre-lift.
523    ///
524    /// Cross-CRD peer to [`crate::prelude::Process::observed_phase`]
525    /// on the (CRD × phase-slot × observed-status) axis pair — both
526    /// primitives walk the identical `.status.as_ref().map(|s| s.
527    /// phase)` shape, differing only in the `Phase` type projected
528    /// ([`AllocationPhase`] vs [`crate::phase::ProcessPhase`]). The
529    /// substrate now owns the borrow-form `.status.as_ref().map(|s|
530    /// s.phase)` chain axis-uniformly across the two `Phase`-having
531    /// CRDs so a future normalization (a generation-filter that
532    /// returns `None` for a phase stamped with a stale
533    /// `metadata.generation`, a staleness gate that drops a phase
534    /// whose observing `phase_since` predates a reconcile deadline,
535    /// a canonicalization pass that maps a phase outside the CRD's
536    /// closed set to `None`) lands at ONE substrate method per CRD
537    /// rather than being restated at every observer.
538    #[must_use]
539    pub fn observed_phase(&self) -> Option<AllocationPhase> {
540        self.status.as_ref().map(|s| s.phase)
541    }
542
543    /// The copy-form status-projection primitive on the phase axis
544    /// with the [`AllocationPhase::Pending`] sink applied — the
545    /// ONE-liner collapse of the paired `self.observed_phase().
546    /// unwrap_or(AllocationPhase::Pending)` incantation the pool
547    /// reconciler's `AllocationConvergenceCtx::observe` restated by
548    /// hand pre-lift as a 5-line `.status.as_ref().map(|s| s.phase).
549    /// unwrap_or(AllocationPhase::Pending)` chain.
550    ///
551    /// Pre-lift the chain sat at [`tatara-pool-reconciler::
552    /// allocation_decide::AllocationConvergenceCtx::observe`]'s
553    /// `phase` seed. Cross-CRD peer to [`crate::prelude::Process::
554    /// observed_phase_or_pending`] on the (CRD × phase-slot × sink)
555    /// axis pair — both primitives close the missing-`status`
556    /// corner with each CRD's respective [`Default`]-equivalent
557    /// `Pending` variant, and both compose on top of their peer
558    /// [`Self::observed_phase`] / [`crate::prelude::Process::
559    /// observed_phase`] borrow-form projections so a future
560    /// normalization at the underlying `observed_phase` primitive
561    /// reaches both the raw-`Option` accessor and the `Pending`-
562    /// sinked composer through the SAME upstream body.
563    ///
564    /// The [`AllocationPhase::Pending`] sink is load-bearing as the
565    /// "not yet observed" default — the pool reconciler's typed
566    /// `AllocationPhase::needs_pool_routing` predicate returns
567    /// `true` for `Pending`, so a freshly-admitted Allocation whose
568    /// pool reconciler has not yet stamped a `.status` slot reads
569    /// as `Pending` and immediately enters the routing ladder,
570    /// matching the pre-lift `AllocationPhase::Pending` fallback
571    /// semantics verbatim.
572    ///
573    /// Theory anchor: THEORY.md §VI.1 (generation over composition
574    /// — the two-link `.status.as_ref().map(|s| s.phase).unwrap_or
575    /// (AllocationPhase::Pending)` chain recurred at both the
576    /// [`crate::prelude::Process`] site (already lifted onto
577    /// [`crate::prelude::Process::observed_phase_or_pending`]) AND
578    /// the [`EphemeralAllocation`] site by hand, i.e. the SHAPE
579    /// itself recurs past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
580    /// trigger, and is lifted to ONE owner per CRD here). THEORY.md
581    /// §II.1 invariant 5 (composition preserves proofs — the pins
582    /// bind the missing-`status` sink to `Pending` + populated-
583    /// status pass-through + every [`AllocationPhase`] variant
584    /// round-trip + byte-identical parity with the pre-lift
585    /// two-link chain + cross-CRD peer coherence with
586    /// [`crate::prelude::Process::observed_phase_or_pending`], so
587    /// a regression that drifted any surface at
588    /// `tests::observed_phase_*` rather than as silent operator-
589    /// facing skew between the allocation observer's routing seed
590    /// and the Process observer's dispatch seed).
591    #[must_use]
592    pub fn observed_phase_or_pending(&self) -> AllocationPhase {
593        self.observed_phase().unwrap_or(AllocationPhase::Pending)
594    }
595
596    /// The borrow-form status-projection primitive on the bound-pool
597    /// axis: returns the [`AllocationRef`] the pool reconciler
598    /// currently persists at `status.bound_pool` (name + namespace of
599    /// the pool that owns the matched member), with the
600    /// missing-`status` corner AND the empty-slot corner BOTH
601    /// collapsed to `None` — the ONE-liner collapse of the paired
602    /// `self.status.as_ref().and_then(|s| s.bound_pool.<clone|as_ref>())`
603    /// incantation the pool reconciler's `AllocationConvergenceCtx::
604    /// observe` restated by hand pre-lift.
605    ///
606    /// Cross-CRD peer to [`crate::prelude::Process::observed_identity`]
607    /// on the (CRD × structured-record-slot × borrow-form) axis pair
608    /// — both primitives walk the identical `.status.as_ref()
609    /// .and_then(|s| s.<slot>.as_ref())` shape, differing only in the
610    /// record projected ([`AllocationRef`] here, [`crate::identity::
611    /// Identity`] on `Process`). The substrate now owns the
612    /// borrow-form `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
613    /// chain on the second `structured-record` slot across the two
614    /// `status`-having CRDs, so a future normalization step (a
615    /// generation-filter that returns `None` for a bound-pool
616    /// reference stamped with a stale `metadata.generation`, a
617    /// canonicalization pass that rejects a malformed
618    /// `(name, namespace)` pair, a cross-cluster reference-rewrite
619    /// gate) lands at ONE substrate method per CRD rather than being
620    /// restated at every observer.
621    ///
622    /// Return-form axis: `Option<&AllocationRef>` mirrors the
623    /// borrow-first discipline of [`crate::prelude::Process::
624    /// observed_identity`]. The lone pre-lift consumer
625    /// ([`tatara-pool-reconciler::allocation_decide::
626    /// AllocationConvergenceCtx::observe`]'s `bound_pool` seed) spelled
627    /// the projection as `.and_then(|s| s.bound_pool.clone())` — an
628    /// eager clone allocated inside every reconcile pass even when the
629    /// downstream branch (the Release-composition arm) needed only the
630    /// borrow for the `.as_ref()` re-projection two lines later.
631    /// Post-lift the consumer reaches the primitive borrow-first
632    /// (`alloc.observed_bound_pool().cloned()`) and the empty-borrow
633    /// corner clones nothing (`Option::cloned` on `None` is `None`);
634    /// the composition point where the owned `AllocationRef` fallback
635    /// is required (the `AllocationConvergenceCtx` snapshot slot,
636    /// still `Option<AllocationRef>`-typed for serde stability) is the
637    /// ONLY site that materializes an owned copy.
638    ///
639    /// The missing-`status` corner AND the populated-status-with-
640    /// `bound_pool=None` corner BOTH collapse to `None` so
641    /// `.is_some()` / `if let Some(_)` / `.cloned()` behave
642    /// identically on an `EphemeralAllocation` whose status field is
643    /// `None` and on one whose status carries an unpopulated
644    /// `bound_pool` slot — matching what the pre-lift `.and_then(...)`
645    /// chain produced. Consumers that need to tell those corners
646    /// apart reach for [`Self::status`] directly, exactly as the
647    /// existing peer accessors [`Self::observed_phase`] +
648    /// [`Self::observed_phase_or_pending`] admit.
649    ///
650    /// Theory anchor: THEORY.md §VI.1 (generation over composition
651    /// — the `.status.as_ref().and_then(|s| s.<structured-record>
652    /// .<clone|as_ref>())` shape recurred as ONE hand-authored
653    /// `.and_then(|s| s.bound_pool.clone())` chain in
654    /// [`tatara-pool-reconciler::allocation_decide::
655    /// AllocationConvergenceCtx::observe`] AND as the peer
656    /// [`crate::prelude::Process::observed_identity`] primitive
657    /// already owned on the `Process` CRD's `status.identity` slot,
658    /// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger at
659    /// substrate-shape level. THEORY.md §II.1 invariant 5
660    /// (composition preserves proofs — the pins bind the missing-
661    /// `status` corner + the empty-`bound_pool`-slot corner + the
662    /// borrow-form `&AllocationRef` lifetime + the zero-copy
663    /// projection contract + byte-identical parity with the pre-lift
664    /// `.and_then(|s| s.bound_pool.clone())` chain across the full
665    /// corner set + cross-CRD peer coherence with
666    /// [`crate::prelude::Process::observed_identity`], so a
667    /// regression that drifted any surface at
668    /// `tests::observed_bound_pool_*` rather than as silent operator-
669    /// facing skew between the allocation observer's Release-
670    /// composition seed and the Process observer's FORK-time
671    /// identity seed on the SAME reconcile tick).
672    #[must_use]
673    pub fn observed_bound_pool(&self) -> Option<&AllocationRef> {
674        self.status.as_ref().and_then(|s| s.bound_pool.as_ref())
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    // `FromStr` lives in scope at the test surface only — the derive
681    // emits `impl ::core::str::FromStr` via the full path so the lib
682    // body no longer reaches `FromStr` directly, but the cross-axis
683    // sweeps + the verbatim-echo contract tests call
684    // `AllocationPhase::from_str(bad)` / `bad.parse::<RequestorKind>()`.
685    use std::str::FromStr;
686
687    use super::*;
688
689    #[test]
690    fn requestor_minimum_shape_round_trips() {
691        let r = Requestor {
692            kind: "github-pr".into(),
693            repo: Some("pleme-io/demo-app".into()),
694            branch: Some("fix-something".into()),
695            pr_number: Some(123),
696            sha: Some("abc123def".into()),
697            pr_labels: vec!["needs-ephemeral".into()],
698            actor: Some("drzln".into()),
699        };
700        let yaml = serde_yaml::to_string(&r).unwrap();
701        assert!(yaml.contains("kind: github-pr"));
702        assert!(yaml.contains("prNumber: 123"));
703        let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
704        assert_eq!(back.kind, "github-pr");
705        assert_eq!(back.pr_number, Some(123));
706    }
707
708    #[test]
709    fn allocation_status_defaults_pending() {
710        let s = AllocationStatus::default();
711        assert_eq!(s.phase, AllocationPhase::Pending);
712        assert!(s.bound_pool.is_none());
713        assert!(s.assigned_process.is_none());
714    }
715
716    #[test]
717    fn allocation_phase_round_trips_via_serde() {
718        for p in [
719            AllocationPhase::Pending,
720            AllocationPhase::Queued,
721            AllocationPhase::Bound,
722            AllocationPhase::Releasing,
723            AllocationPhase::Released,
724            AllocationPhase::NoMatchingPool,
725            AllocationPhase::Failed,
726        ] {
727            let s = serde_yaml::to_string(&p).unwrap();
728            let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
729            assert_eq!(back, p);
730        }
731    }
732
733    // ── closed-set algebra contracts for AllocationPhase
734    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
735
736    /// `ALL` is the source of truth — pin its closure so a variant
737    /// added without an `ALL` entry fails here via the uniqueness
738    /// check before drifting `FromStr` or the sweep tests below. The
739    /// arity is asserted by the `[Self; 7]` array type itself.
740    ///
741    /// Structural well-formedness of [`AllocationPhase`] as a
742    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
743    /// testkit lift that pins all three structural invariants
744    /// (`ALL` is non-empty, every variant round-trips through
745    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
746    /// outside the closed set) at ONE call site. Replaces the hand-
747    /// derived `allocation_phase_all_is_unique_and_complete` +
748    /// `allocation_phase_roundtrip_via_as_str` + the empty-input arm
749    /// of `unknown_allocation_phase_errors`. `FromStr` delegates to
750    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
751    /// helper exercises the same code path the allocation reconciler
752    /// hits when parsing a CRD `enum:`-validated value back to the
753    /// typed phase.
754    #[test]
755    fn allocation_phase_is_well_formed_closed_set() {
756        tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>();
757    }
758
759    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
760    /// output verbatim for every variant. A future variant rename
761    /// (or an `as_str` arm typo) lands here at one site, instead of
762    /// drifting between the typed surface, the CRD enum, the YAML
763    /// wire format, and the operator-facing reason strings the
764    /// reconciler stamps via Display.
765    #[test]
766    fn allocation_phase_as_str_matches_serde() {
767        crate::tagged_union::assert_label_matches_serde_serialization::<AllocationPhase>();
768    }
769
770    /// The Display impl IS `as_str` — pinning this lets future
771    /// callers reach for either projection without drift.
772    #[test]
773    fn allocation_phase_display_matches_as_str() {
774        crate::tagged_union::assert_display_matches_label::<AllocationPhase>();
775    }
776
777    /// `FromStr` rejects strings that aren't in the canonical
778    /// projection — lowercased / typo / unrelated — and the error
779    /// echoes the input verbatim so the operator-facing diagnostic
780    /// carries the offending value, not a normalized form. The
781    /// empty-input arm is pinned by
782    /// [`allocation_phase_is_well_formed_closed_set`] via the
783    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
784    /// verbatim-echo contract on the [`UnknownAllocationPhase`]
785    /// newtype, which the trait's `make_unknown` can't see.
786    #[test]
787    fn unknown_allocation_phase_errors() {
788        for bad in [
789            "pending",
790            "BOUND",
791            "no-matching-pool",
792            "release",
793            "failed_state",
794            "Reaped",
795        ] {
796            let err = AllocationPhase::from_str(bad).unwrap_err();
797            assert_eq!(err.0, bad, "error payload should echo input verbatim");
798        }
799    }
800
801    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
802    /// documented per-variant disposition. `Released` + `Failed` are
803    /// terminal (absorbing); `Pending` / `Queued` / `NoMatchingPool`
804    /// need pool routing; `Bound` / `Releasing` are settled-but-not-
805    /// terminal (heartbeat / release ladder).
806    #[test]
807    fn allocation_phase_predicate_truth_tables() {
808        assert!(!AllocationPhase::Pending.is_terminal());
809        assert!(AllocationPhase::Pending.needs_pool_routing());
810
811        assert!(!AllocationPhase::Queued.is_terminal());
812        assert!(AllocationPhase::Queued.needs_pool_routing());
813
814        assert!(!AllocationPhase::Bound.is_terminal());
815        assert!(!AllocationPhase::Bound.needs_pool_routing());
816
817        assert!(!AllocationPhase::Releasing.is_terminal());
818        assert!(!AllocationPhase::Releasing.needs_pool_routing());
819
820        assert!(AllocationPhase::Released.is_terminal());
821        assert!(!AllocationPhase::Released.needs_pool_routing());
822
823        assert!(!AllocationPhase::NoMatchingPool.is_terminal());
824        assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
825
826        assert!(AllocationPhase::Failed.is_terminal());
827        assert!(!AllocationPhase::Failed.needs_pool_routing());
828    }
829
830    /// IMPLICATION CONTRACT: `is_terminal → !needs_pool_routing`. A
831    /// terminal allocation cannot also be routing-eligible — that's
832    /// the bug the typed projection closes (a `Failed` allocation
833    /// that's neither `Released` nor `Bound` would otherwise slip
834    /// through the open-coded gate in `observe` and try to rebind to
835    /// a pool member). A future variant that flipped both predicates
836    /// true would fail here, forcing the author to flip one or
837    /// extend the consumer dispatch site in
838    /// `tatara-pool-reconciler::allocation_decide` deliberately
839    /// rather than letting an impossible state slip in.
840    #[test]
841    fn allocation_phase_terminal_excludes_routing() {
842        for phase in AllocationPhase::ALL {
843            assert!(
844                !(phase.is_terminal() && phase.needs_pool_routing()),
845                "{phase:?} is both terminal and routing-eligible",
846            );
847        }
848    }
849
850    /// DEFAULT-AGREEMENT CONTRACT: `AllocationPhase::default()` is
851    /// `Pending` — the entry state, neither terminal nor settled —
852    /// and it lives on the routing path. A future default-variant
853    /// rename without flipping the predicates fails here.
854    #[test]
855    fn allocation_phase_default_is_pending_and_routes() {
856        let d = AllocationPhase::default();
857        assert_eq!(d, AllocationPhase::Pending);
858        assert!(!d.is_terminal());
859        assert!(d.needs_pool_routing());
860    }
861
862    // ── RequestorKind closed-set truth-table ─────────────────────────
863
864    /// Structural well-formedness of [`RequestorKind`] as a
865    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
866    /// testkit lift that pins all three structural invariants
867    /// (`ALL` is non-empty, every variant round-trips through
868    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
869    /// outside the closed set) at ONE call site. Replaces the hand-
870    /// derived `requestor_kind_all_enumerates_each_variant_exactly_once`
871    /// + `requestor_kind_from_str_round_trips_canonical_names` + the
872    /// empty-input arm of `requestor_kind_from_str_rejects_open_kinds`.
873    /// `FromStr` delegates to
874    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
875    /// exercises the same code path
876    /// [`Requestor::known_kind`]'s `Option<RequestorKind>` collapse
877    /// rides on when classifying inbound `Requestor.kind` strings. The
878    /// arity is asserted by the `[Self; 4]` array type itself.
879    #[test]
880    fn requestor_kind_is_well_formed_closed_set() {
881        tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>();
882    }
883
884    /// Byte-exact wire-format pin — renaming any of these is a wire-
885    /// format change (the `tatara-github-watcher` emitter, the CRD
886    /// printcolumns, the `PoolSelector.kinds` filter strings, the
887    /// per-test `kind: "…".into()` fixtures all depend on these
888    /// literals), not a typed-internal refactor.
889    #[test]
890    fn requestor_kind_canonical_names_pinned() {
891        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
892        assert_eq!(RequestorKind::Manual.as_str(), "manual");
893        assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
894        assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
895    }
896
897    /// `FromStr` rejects strings that aren't in the canonical
898    /// projection — lowercased-mismatch / typo / unrelated — and the
899    /// error echoes the input verbatim so the operator-facing
900    /// diagnostic carries the offending value, not a normalized form.
901    /// The schema is open at the wire layer (operators MAY register
902    /// new kinds and `Requestor::known_kind` collapses them to
903    /// `None`), but the closed-set view is byte-exact. The empty-input
904    /// arm is pinned by [`requestor_kind_is_well_formed_closed_set`]
905    /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
906    /// the verbatim-echo contract on the [`UnknownRequestorKind`]
907    /// newtype, which the trait's `make_unknown` can't see.
908    #[test]
909    fn requestor_kind_from_str_rejects_open_kinds() {
910        for bad in [
911            "github_pr",
912            "GithubPr",
913            "operator-custom-kind",
914            "ci_run",
915            "Scheduled",
916        ] {
917            let err = bad.parse::<RequestorKind>().unwrap_err();
918            assert_eq!(err, UnknownRequestorKind(bad.to_string()));
919        }
920    }
921
922    /// The Display impl IS `as_str` — pinning this lets future
923    /// callers reach for either projection without drift (Display is
924    /// what operator-facing diagnostics compose against).
925    #[test]
926    fn requestor_kind_display_delegates_to_as_str() {
927        for k in RequestorKind::ALL {
928            assert_eq!(format!("{k}"), k.as_str());
929        }
930    }
931
932    /// The `String` projection that `From<RequestorKind> for String`
933    /// ([`RequestorKind::into`]) composes is byte-equal to `as_str`.
934    /// This is the typed → wire bridge — emitters spell
935    /// `kind: RequestorKind::GithubPr.into()` and the canonical
936    /// literal is materialized at ONE place.
937    #[test]
938    fn requestor_kind_into_string_matches_as_str() {
939        for k in RequestorKind::ALL {
940            let s: String = k.into();
941            assert_eq!(s, k.as_str());
942        }
943    }
944
945    /// The typed → wire → typed round-trip: composing a `Requestor`
946    /// with `kind: RequestorKind::X.into()` produces an object whose
947    /// `known_kind()` decodes back to `X`. Pins the bridge invariant
948    /// at the `Requestor` boundary, not just at `RequestorKind`.
949    #[test]
950    fn known_kind_decodes_built_requestors() {
951        for k in RequestorKind::ALL {
952            let r = Requestor {
953                kind: k.into(),
954                repo: None,
955                branch: None,
956                pr_number: None,
957                sha: None,
958                pr_labels: vec![],
959                actor: None,
960            };
961            assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
962        }
963    }
964
965    /// Open-by-design: a custom operator-registered kind still
966    /// stamps a valid `Requestor` (no schema rejection), it just
967    /// doesn't project through the closed-set typed view. Mirrors
968    /// `ReceiptEnvelope::known_kind`'s open-kind posture.
969    #[test]
970    fn known_kind_returns_none_for_open_kinds() {
971        let r = Requestor {
972            kind: "operator-custom-kind".into(),
973            repo: None,
974            branch: None,
975            pr_number: None,
976            sha: None,
977            pr_labels: vec![],
978            actor: None,
979        };
980        assert_eq!(r.known_kind(), None);
981    }
982
983    /// The four canonical literals match every previously-published
984    /// fixture / doc anchor in this crate — pinning the bridge to
985    /// existing call sites so any drift fails here before the next
986    /// release ships.
987    #[test]
988    fn requestor_kind_matches_existing_fixture_literals() {
989        // The `requestor_minimum_shape_round_trips` fixture above
990        // composes `kind: "github-pr".into()` verbatim.
991        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
992        // The `allocation_spec_omits_optional_fields` fixture below
993        // composes `kind: "manual".into()` verbatim.
994        assert_eq!(RequestorKind::Manual.as_str(), "manual");
995    }
996
997    // Per-implementor `unknown_X_message_matches_substrate_convention`
998    // tests removed — clause (5) of
999    // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
1000    // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
1001    // generically (called above on `RequestorKind` /
1002    // `AllocationPhase` through their `*_is_well_formed_closed_set`
1003    // sites). The `SET_LABEL` projection is pinned independently by
1004    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
1005    // together the two contracts guarantee the operator-facing
1006    // diagnostic without needing per-enum literal pins.
1007
1008    // ─── EphemeralAllocation::observed_phase* substrate pins ────────
1009    //
1010    // Fail-before-pass-after granularity: neither `observed_phase` nor
1011    // `observed_phase_or_pending` existed before this commit, so each
1012    // pin fails to compile until the corresponding inherent method
1013    // lands. Post-lift the pins bind the missing-`status` corner + the
1014    // populated-status pass-through + byte-identical parity with the
1015    // pre-lift 5-line `.status.as_ref().map(|s| s.phase).unwrap_or
1016    // (AllocationPhase::Pending)` chain the pool reconciler's
1017    // `AllocationConvergenceCtx::observe` walked. Cross-CRD peer
1018    // coherence with `Process::observed_phase_or_pending` is pinned
1019    // by the `_matches_process_peer_shape` sweep at the tail.
1020
1021    fn alloc_with_phase(phase: AllocationPhase) -> EphemeralAllocation {
1022        let spec = AllocationSpec {
1023            pool_ref: None,
1024            requestor: Requestor {
1025                kind: "manual".into(),
1026                repo: None,
1027                branch: None,
1028                pr_number: None,
1029                sha: None,
1030                pr_labels: vec![],
1031                actor: None,
1032            },
1033            ttl: None,
1034            note: None,
1035        };
1036        let mut a = EphemeralAllocation::new("obs-alloc", spec);
1037        a.status = Some(AllocationStatus {
1038            phase,
1039            ..AllocationStatus::default()
1040        });
1041        a
1042    }
1043
1044    fn alloc_without_status() -> EphemeralAllocation {
1045        let spec = AllocationSpec {
1046            pool_ref: None,
1047            requestor: Requestor {
1048                kind: "manual".into(),
1049                repo: None,
1050                branch: None,
1051                pr_number: None,
1052                sha: None,
1053                pr_labels: vec![],
1054                actor: None,
1055            },
1056            ttl: None,
1057            note: None,
1058        };
1059        let mut a = EphemeralAllocation::new("no-status-alloc", spec);
1060        a.status = None;
1061        a
1062    }
1063
1064    #[test]
1065    fn observed_phase_returns_none_when_status_is_none() {
1066        let a = alloc_without_status();
1067        assert!(a.observed_phase().is_none());
1068    }
1069
1070    #[test]
1071    fn observed_phase_returns_populated_variant_verbatim() {
1072        for p in AllocationPhase::ALL {
1073            let a = alloc_with_phase(p);
1074            assert_eq!(
1075                a.observed_phase(),
1076                Some(p),
1077                "observed_phase must project the persisted variant verbatim for {p:?}"
1078            );
1079        }
1080    }
1081
1082    #[test]
1083    fn observed_phase_matches_pre_lift_chain_bytewise() {
1084        // Sweep every corner: (status: None) plus every populated
1085        // (status: Some(phase)) variant. The pre-lift chain was
1086        // `alloc.status.as_ref().map(|s| s.phase)` — a 3-link chain
1087        // hand-authored inline at the observer. The primitive must
1088        // return the same `Option<AllocationPhase>` on every corner.
1089        let none_alloc = alloc_without_status();
1090        assert_eq!(
1091            none_alloc.observed_phase(),
1092            none_alloc.status.as_ref().map(|s| s.phase),
1093        );
1094        for p in AllocationPhase::ALL {
1095            let a = alloc_with_phase(p);
1096            assert_eq!(
1097                a.observed_phase(),
1098                a.status.as_ref().map(|s| s.phase),
1099                "primitive must be byte-identical to the pre-lift chain for {p:?}",
1100            );
1101        }
1102    }
1103
1104    #[test]
1105    fn observed_phase_or_pending_defaults_to_pending_when_status_absent() {
1106        let a = alloc_without_status();
1107        assert_eq!(a.observed_phase_or_pending(), AllocationPhase::Pending);
1108    }
1109
1110    #[test]
1111    fn observed_phase_or_pending_returns_populated_phase_verbatim() {
1112        for p in AllocationPhase::ALL {
1113            let a = alloc_with_phase(p);
1114            assert_eq!(
1115                a.observed_phase_or_pending(),
1116                p,
1117                "populated status must pass through verbatim for {p:?}"
1118            );
1119        }
1120    }
1121
1122    #[test]
1123    fn observed_phase_or_pending_defaults_agree_with_allocation_phase_default() {
1124        // The `Pending` sink is load-bearing as the "not yet observed"
1125        // default. `AllocationPhase::default()` returns `Pending`; the
1126        // primitive must return the same variant on the missing-status
1127        // corner. A future default-variant rename that flipped
1128        // `AllocationPhase::default` without flipping the primitive
1129        // (or vice versa) surfaces here as a divergent seed for the
1130        // routing ladder.
1131        let a = alloc_without_status();
1132        assert_eq!(a.observed_phase_or_pending(), AllocationPhase::default());
1133    }
1134
1135    #[test]
1136    fn observed_phase_or_pending_matches_pre_lift_chain_bytewise() {
1137        // The exact pre-lift 5-line chain in
1138        // `tatara-pool-reconciler::allocation_decide::
1139        // AllocationConvergenceCtx::observe` was:
1140        //     let phase = alloc
1141        //         .status
1142        //         .as_ref()
1143        //         .map(|s| s.phase)
1144        //         .unwrap_or(AllocationPhase::Pending);
1145        // Sweep every corner: (status: None) plus every populated
1146        // status variant. The primitive must be byte-identical for
1147        // every corner so the observer's routing decision matches
1148        // bytewise post-lift.
1149        let none_alloc = alloc_without_status();
1150        assert_eq!(
1151            none_alloc.observed_phase_or_pending(),
1152            none_alloc
1153                .status
1154                .as_ref()
1155                .map(|s| s.phase)
1156                .unwrap_or(AllocationPhase::Pending),
1157        );
1158        for p in AllocationPhase::ALL {
1159            let a = alloc_with_phase(p);
1160            assert_eq!(
1161                a.observed_phase_or_pending(),
1162                a.status
1163                    .as_ref()
1164                    .map(|s| s.phase)
1165                    .unwrap_or(AllocationPhase::Pending),
1166                "primitive must be byte-identical to the pre-lift 5-line chain for {p:?}",
1167            );
1168        }
1169    }
1170
1171    #[test]
1172    fn observed_phase_or_pending_composes_from_observed_phase() {
1173        // The composer sits on top of the borrow-form projection —
1174        // `observed_phase_or_pending() == observed_phase().unwrap_or
1175        // (Pending)`. Pinning the composition means a future
1176        // normalization step layered onto `observed_phase` (a
1177        // generation-filter, a staleness gate, a canonicalization
1178        // pass) reaches BOTH the raw-`Option` accessor and the
1179        // `Pending`-sinked composer through the SAME upstream body,
1180        // without needing a per-corner rewrite of the composer.
1181        let none_alloc = alloc_without_status();
1182        assert_eq!(
1183            none_alloc.observed_phase_or_pending(),
1184            none_alloc
1185                .observed_phase()
1186                .unwrap_or(AllocationPhase::Pending),
1187        );
1188        for p in AllocationPhase::ALL {
1189            let a = alloc_with_phase(p);
1190            assert_eq!(
1191                a.observed_phase_or_pending(),
1192                a.observed_phase().unwrap_or(AllocationPhase::Pending),
1193                "composer must ride on top of the borrow-form projection for {p:?}",
1194            );
1195        }
1196    }
1197
1198    #[test]
1199    fn observed_phase_is_a_pure_projection() {
1200        // Reading the phase twice must not mutate the allocation or
1201        // its status slot — pure projection semantics. Also witnesses
1202        // that the accessor doesn't clone / drop the inner `phase`
1203        // (the `Copy` scalar comes out identical on both reads).
1204        let a = alloc_with_phase(AllocationPhase::Bound);
1205        let one = a.observed_phase();
1206        let two = a.observed_phase();
1207        assert_eq!(one, two);
1208        assert!(a.status.is_some(), "projection must not consume the status");
1209    }
1210
1211    #[test]
1212    fn observed_phase_pending_missing_status_and_populated_pending_collapse_to_same_composer_output(
1213    ) {
1214        // A subtle correctness pin: the missing-`status` corner and
1215        // a populated-with-Pending status BOTH read as `Pending`
1216        // through the composer — the observer cannot distinguish the
1217        // two through this accessor. This matches the pre-lift 5-line
1218        // chain's semantics exactly (an operator patching
1219        // `status.phase: Pending` is indistinguishable from a
1220        // freshly-admitted allocation with no status stamped yet).
1221        // The borrow-form `observed_phase` accessor DOES distinguish
1222        // the two, so a caller that needs to tell them apart reaches
1223        // for the raw `Option`.
1224        let none_alloc = alloc_without_status();
1225        let pending_alloc = alloc_with_phase(AllocationPhase::Pending);
1226
1227        assert_eq!(
1228            none_alloc.observed_phase_or_pending(),
1229            pending_alloc.observed_phase_or_pending(),
1230        );
1231        assert_ne!(
1232            none_alloc.observed_phase(),
1233            pending_alloc.observed_phase(),
1234            "borrow-form accessor MUST distinguish missing-status from populated-Pending",
1235        );
1236    }
1237
1238    #[test]
1239    fn observed_phase_or_pending_missing_status_sink_agrees_with_process_peer_shape() {
1240        // Cross-CRD peer-axis coherence with
1241        // `Process::observed_phase_or_pending`. Both primitives walk
1242        // the identical `.status.as_ref().map(|s| s.phase).unwrap_or
1243        // (<Phase>::Pending)` chain differing ONLY in the `Phase`
1244        // type projected. On a missing-status observation, each
1245        // primitive must return its CRD's `Default`-equivalent
1246        // `Pending` variant — for `EphemeralAllocation` that's
1247        // `AllocationPhase::Pending`; for `Process` that's
1248        // `crate::phase::ProcessPhase::Pending`. This pin binds the
1249        // sink-parity structurally so a future rename of either
1250        // default variant surfaces here as a divergent seed for the
1251        // observer's routing / dispatch decision rather than as
1252        // silent drift between the two reconcilers.
1253        let no_status_alloc = alloc_without_status();
1254        assert_eq!(
1255            no_status_alloc.observed_phase_or_pending(),
1256            AllocationPhase::default(),
1257        );
1258        // Peer-axis invariant on the `Process` side — the primitive
1259        // that owns the same shape reads `ProcessPhase::Pending` on
1260        // the missing-status corner via its own inherent method. The
1261        // parity is coordinated at the `Default` seat: both CRDs'
1262        // phase types default to `Pending`, so a rename that broke
1263        // one without the other would fail one of these two
1264        // conjoined assertions.
1265        assert_eq!(AllocationPhase::default(), AllocationPhase::Pending,);
1266        assert_eq!(
1267            crate::phase::ProcessPhase::default(),
1268            crate::phase::ProcessPhase::Pending,
1269        );
1270    }
1271
1272    // ─── EphemeralAllocation::observed_bound_pool substrate pins ────
1273    //
1274    // The borrow-form status-projection primitive on the bound-pool
1275    // axis. Collapses the pre-lift hand-authored `.status.as_ref()
1276    // .and_then(|s| s.bound_pool.clone())` chain in
1277    // `tatara-pool-reconciler::allocation_decide::
1278    // AllocationConvergenceCtx::observe`'s `bound_pool` seed onto the
1279    // ONE substrate primitive. Cross-CRD peer to
1280    // `Process::observed_identity` on the (CRD × structured-record-
1281    // slot × borrow-form) axis pair — both primitives walk the
1282    // identical `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
1283    // shape. Each pin is fail-before-pass-after: `observed_bound_pool`
1284    // did not exist pre-lift, so any test invoking it fails to compile
1285    // pre-lift and passes post-lift.
1286
1287    fn sample_pool_ref(name: &str, ns: &str) -> AllocationRef {
1288        AllocationRef {
1289            name: name.to_string(),
1290            namespace: ns.to_string(),
1291        }
1292    }
1293
1294    fn alloc_with_bound_pool(bound: Option<AllocationRef>) -> EphemeralAllocation {
1295        let spec = AllocationSpec {
1296            pool_ref: None,
1297            requestor: Requestor {
1298                kind: "manual".into(),
1299                repo: None,
1300                branch: None,
1301                pr_number: None,
1302                sha: None,
1303                pr_labels: vec![],
1304                actor: None,
1305            },
1306            ttl: None,
1307            note: None,
1308        };
1309        let mut a = EphemeralAllocation::new("bp-alloc", spec);
1310        a.status = Some(AllocationStatus {
1311            phase: AllocationPhase::Bound,
1312            bound_pool: bound,
1313            ..AllocationStatus::default()
1314        });
1315        a
1316    }
1317
1318    #[test]
1319    fn observed_bound_pool_returns_none_when_status_is_none() {
1320        // Missing-`status` corner pin: the primitive collapses the
1321        // no-status case to `None` so downstream `.is_some()` /
1322        // `if let Some(_)` / `.cloned().unwrap_or_else(...)` behave
1323        // identically on an `EphemeralAllocation` whose status field
1324        // is `None` and on one whose status carries an unpopulated
1325        // `bound_pool` slot. Matches the pre-lift `.and_then(...)`
1326        // chain's `None` byte-identically at the pool reconciler's
1327        // Release-composition seed.
1328        let a = alloc_without_status();
1329        assert!(a.observed_bound_pool().is_none());
1330    }
1331
1332    #[test]
1333    fn observed_bound_pool_returns_none_when_slot_is_none() {
1334        // Empty-slot-under-populated-status corner pin: the primitive
1335        // returns `None`, matching the missing-`status` corner byte-
1336        // identically. A regression that treated the two corners
1337        // differently would silently promote an internal representation
1338        // detail (whether the pool reconciler has ever written a
1339        // status subresource) into observable behavior at the
1340        // Release-composition branch of the allocation reconciler's
1341        // `decide` transition rule.
1342        let a = alloc_with_bound_pool(None);
1343        assert!(a.observed_bound_pool().is_none());
1344    }
1345
1346    #[test]
1347    fn observed_bound_pool_returns_borrow_when_slot_is_populated() {
1348        // Happy-path pin: with a populated `status.bound_pool` slot,
1349        // the primitive returns a borrowed `&AllocationRef` whose
1350        // (name, namespace) fields match the persisted record. A
1351        // regression that filtered / reshaped / canonicalized the
1352        // record would surface here rather than as silent skew at the
1353        // Release-composition seed's `.cloned()` materialization.
1354        let expected = sample_pool_ref("demo-pool", "pools");
1355        let a = alloc_with_bound_pool(Some(expected.clone()));
1356        let observed = a.observed_bound_pool().expect("populated slot");
1357        assert_eq!(observed, &expected);
1358        assert_eq!(observed.name, "demo-pool");
1359        assert_eq!(observed.namespace, "pools");
1360    }
1361
1362    #[test]
1363    fn observed_bound_pool_is_a_zero_copy_borrow_projection() {
1364        // Borrow-discipline pin: the returned reference points at the
1365        // persisted `AllocationRef` in place — NOT a fresh allocation
1366        // or a clone. A regression that switched the projection to an
1367        // owned `AllocationRef` (via `.clone()`) would defeat the
1368        // zero-copy contract the lift's primary strict-widening
1369        // delivers (the observer's Release-composition arm clones
1370        // once at the composition point where the
1371        // `AllocationConvergenceCtx` snapshot slot requires the owned
1372        // value). Peer to the sibling
1373        // `Process::observed_identity_is_a_zero_copy_borrow_projection`
1374        // pin on the `Process` CRD's `status.identity` slot.
1375        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1376        let observed = a.observed_bound_pool().expect("populated slot") as *const _;
1377        let persisted = a.status.as_ref().unwrap().bound_pool.as_ref().unwrap() as *const _;
1378        assert!(std::ptr::eq(observed, persisted));
1379    }
1380
1381    #[test]
1382    fn observed_bound_pool_is_a_pure_projection() {
1383        // Purity pin: calling the projection twice on the same
1384        // `EphemeralAllocation` returns byte-identical borrows (same
1385        // pointer). A regression that introduced state — a lazy-
1386        // cached reference, a normalization step that ran once and
1387        // cached — would surface here rather than as silent drift
1388        // between two dispatches within one reconcile pass.
1389        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1390        let one = a.observed_bound_pool().expect("populated slot") as *const _;
1391        let two = a.observed_bound_pool().expect("populated slot") as *const _;
1392        assert!(std::ptr::eq(one, two));
1393    }
1394
1395    #[test]
1396    fn observed_bound_pool_matches_pre_lift_chain_bytewise() {
1397        // Byte-identical parity pin between the borrow-form primitive
1398        // here and the pre-lift `tatara-pool-reconciler`
1399        // `.status.as_ref().and_then(|s| s.bound_pool.clone())` chain.
1400        // Sweeps every corner every callsite plausibly encounters
1401        // (missing status, empty `bound_pool` slot, populated
1402        // `bound_pool` slot). A regression that inserted a
1403        // normalization step at the primitive the pre-lift chain does
1404        // NOT apply — or vice versa — surfaces here rather than as
1405        // silent drift between the pre-lift consumer site and the ONE
1406        // substrate owner it now routes through.
1407        fn pre_lift(a: &EphemeralAllocation) -> Option<AllocationRef> {
1408            a.status.as_ref().and_then(|s| s.bound_pool.clone())
1409        }
1410        // Missing status.
1411        let a = alloc_without_status();
1412        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1413        // Populated status, empty `bound_pool` slot.
1414        let a = alloc_with_bound_pool(None);
1415        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1416        // Populated status, populated `bound_pool` slot.
1417        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1418        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1419    }
1420
1421    #[test]
1422    fn observed_bound_pool_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
1423        // Cross-corner coherence pin: the missing-`status` corner and
1424        // the populated-empty-slot corner return `Option`s whose
1425        // `.is_none()` / `.is_some()` observations are IDENTICAL. A
1426        // regression that promoted the missing-`status` corner to a
1427        // typed error (via a signature change to `Result<_, _>`) — or
1428        // that widened the empty-slot corner to a synthetic
1429        // `Some(AllocationRef::default())` — would surface here rather
1430        // than as silent operator-facing divergence between a never-
1431        // status-written allocation and a bound-pool-cleared
1432        // allocation on the Release-composition branch.
1433        let a_no_status = alloc_without_status();
1434        let a_empty_slot = alloc_with_bound_pool(None);
1435        assert_eq!(
1436            a_no_status.observed_bound_pool().is_none(),
1437            a_empty_slot.observed_bound_pool().is_none(),
1438        );
1439        assert_eq!(
1440            a_no_status.observed_bound_pool().is_some(),
1441            a_empty_slot.observed_bound_pool().is_some(),
1442        );
1443    }
1444
1445    #[test]
1446    fn observed_bound_pool_shape_agrees_with_process_observed_identity_peer_axis() {
1447        // Cross-CRD peer-axis coherence pin binding the SAME
1448        // `.status.as_ref().and_then(|s| s.<slot>.as_ref())` shape
1449        // that both `EphemeralAllocation::observed_bound_pool` (this
1450        // primitive) and `Process::observed_identity` walk, differing
1451        // ONLY in the record projected. Structural test — both
1452        // signatures must resolve as `&Self -> Option<&Record>` fn
1453        // pointers, so a future rename or a signature drift that
1454        // (say) widened one side to `Option<Record>` or narrowed one
1455        // side to `Option<&str>` fails to compile here rather than
1456        // silently drifting the two reconcilers apart at their
1457        // respective observer seeds. The runtime side of the pin
1458        // sweeps the missing-status + empty-slot corners on the
1459        // `EphemeralAllocation` half; the `Process` half is exercised
1460        // by its own `crd.rs::tests::observed_identity_*` pin
1461        // family — this test binds only the peer-axis shape.
1462        let a_no_status = alloc_without_status();
1463        let a_empty_slot = alloc_with_bound_pool(None);
1464        assert!(a_no_status.observed_bound_pool().is_none());
1465        assert!(a_empty_slot.observed_bound_pool().is_none());
1466        // Structural peer-axis coherence: bind both signatures as fn
1467        // pointers at their peer resolution type so the compiler
1468        // refuses to build if either side's shape drifts. The `_`
1469        // let-bindings assert the target type inference.
1470        let _bound_pool_shape: fn(&EphemeralAllocation) -> Option<&AllocationRef> =
1471            EphemeralAllocation::observed_bound_pool;
1472        let _identity_shape: fn(&crate::prelude::Process) -> Option<&crate::identity::Identity> =
1473            crate::prelude::Process::observed_identity;
1474    }
1475
1476    #[test]
1477    fn allocation_spec_omits_optional_fields() {
1478        let s = AllocationSpec {
1479            pool_ref: None,
1480            requestor: Requestor {
1481                kind: "manual".into(),
1482                repo: None,
1483                branch: None,
1484                pr_number: None,
1485                sha: None,
1486                pr_labels: vec![],
1487                actor: None,
1488            },
1489            ttl: None,
1490            note: None,
1491        };
1492        let yaml = serde_yaml::to_string(&s).unwrap();
1493        assert!(!yaml.contains("poolRef"));
1494        assert!(!yaml.contains("ttl"));
1495        assert!(!yaml.contains("note"));
1496    }
1497}