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    /// The copy-form status-projection primitive on the TTL-expiry axis:
678    /// returns the wall-clock deadline the pool reconciler currently
679    /// persists at `status.expires_at` (derived from `spec.ttl` +
680    /// `allocated_at` at Bind time), wrapped in an `Option` so both the
681    /// missing-`status` corner AND the populated-status-with-`expires_at
682    /// =None` corner collapse to `None` — the ONE-liner collapse of the
683    /// paired `self.status.as_ref().and_then(|s| s.expires_at)`
684    /// incantation the pool reconciler's `AllocationConvergenceCtx::
685    /// observe` restated by hand pre-lift.
686    ///
687    /// Same-CRD peer to [`Self::observed_phase`] on the (CRD × copy-form
688    /// × status-slot) axis pair — both primitives walk the identical
689    /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape,
690    /// differing only in the record projected ([`DateTime<Utc>`] here,
691    /// [`AllocationPhase`] on the phase axis) and in the outer combinator
692    /// (`and_then` here because the persisted field is itself an
693    /// `Option<DateTime<Utc>>`, `map` there because the persisted phase
694    /// is bare). The substrate now owns the copy-form
695    /// `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` chain
696    /// axis-uniformly across every `Copy`-valued slot on
697    /// `AllocationStatus`, so a future normalization (a clock-skew
698    /// guard that drops an `expires_at` stamped before its owning
699    /// allocation's observed `allocated_at`, a canonicalization pass
700    /// that clamps a deadline to a monotonic upper bound, a stale-
701    /// timestamp gate that returns `None` on an `expires_at` older than
702    /// a controller-configured horizon) lands at ONE substrate method
703    /// rather than being restated at every observer.
704    ///
705    /// Return-form axis: `Option<DateTime<Utc>>` mirrors the copy-first
706    /// discipline of [`Self::observed_phase`]. The lone pre-lift consumer
707    /// ([`tatara-pool-reconciler::allocation_decide::
708    /// AllocationConvergenceCtx::observe`]'s `expires_at` seed) spelled
709    /// the projection as `.status.as_ref().and_then(|s| s.expires_at)` —
710    /// a 3-link hand-authored chain the observer walked on every
711    /// reconcile pass. Post-lift the consumer reaches the primitive
712    /// once and the whole missing-status + empty-slot corner cross
713    /// collapses at the substrate rather than at the callsite.
714    ///
715    /// The missing-`status` corner AND the populated-status-with-
716    /// `expires_at=None` corner BOTH collapse to `None` so
717    /// `.is_some()` / `if let Some(_)` / any `>=` deadline comparison
718    /// behave identically on an `EphemeralAllocation` whose status
719    /// field is `None` and on one whose status carries an unpopulated
720    /// `expires_at` slot — matching what the pre-lift `.and_then(...)`
721    /// chain produced. Consumers that need to tell those corners apart
722    /// reach for [`Self::status`] directly, exactly as the existing peer
723    /// accessors [`Self::observed_phase`] +
724    /// [`Self::observed_phase_or_pending`] admit.
725    ///
726    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
727    /// the `.status.as_ref().and_then(|s| s.<Copy-field>)` shape
728    /// recurred as ONE hand-authored chain in
729    /// [`tatara-pool-reconciler::allocation_decide::
730    /// AllocationConvergenceCtx::observe`] AND as the copy-form peer
731    /// [`Self::observed_phase`] primitive already owned on the same
732    /// CRD's `status.phase` slot, past the substrate-shape recurrence
733    /// trigger; the substrate now owns the third status-projection
734    /// primitive on `EphemeralAllocation`, closing the copy-form family
735    /// alongside the borrow-form [`Self::observed_bound_pool`]).
736    /// THEORY.md §II.1 invariant 5 (composition preserves proofs — the
737    /// pins bind the missing-`status` corner + the empty-`expires_at`-
738    /// slot corner + the copy-form `DateTime<Utc>` return + byte-
739    /// identical parity with the pre-lift `.and_then(|s| s.expires_at)`
740    /// chain across the full corner set, so a regression that drifted
741    /// any surface surfaces at `tests::observed_expires_at_*` rather
742    /// than as silent operator-facing skew between the allocation
743    /// observer's Release-composition TTL gate and any future consumer
744    /// that reaches for the same slot).
745    #[must_use]
746    pub fn observed_expires_at(&self) -> Option<DateTime<Utc>> {
747        self.status.as_ref().and_then(|s| s.expires_at)
748    }
749}
750
751#[cfg(test)]
752mod tests {
753    // `FromStr` lives in scope at the test surface only — the derive
754    // emits `impl ::core::str::FromStr` via the full path so the lib
755    // body no longer reaches `FromStr` directly, but the cross-axis
756    // sweeps + the verbatim-echo contract tests call
757    // `AllocationPhase::from_str(bad)` / `bad.parse::<RequestorKind>()`.
758    use std::str::FromStr;
759
760    use super::*;
761
762    #[test]
763    fn requestor_minimum_shape_round_trips() {
764        let r = Requestor {
765            kind: "github-pr".into(),
766            repo: Some("pleme-io/demo-app".into()),
767            branch: Some("fix-something".into()),
768            pr_number: Some(123),
769            sha: Some("abc123def".into()),
770            pr_labels: vec!["needs-ephemeral".into()],
771            actor: Some("drzln".into()),
772        };
773        let yaml = serde_yaml::to_string(&r).unwrap();
774        assert!(yaml.contains("kind: github-pr"));
775        assert!(yaml.contains("prNumber: 123"));
776        let back: Requestor = serde_yaml::from_str(&yaml).unwrap();
777        assert_eq!(back.kind, "github-pr");
778        assert_eq!(back.pr_number, Some(123));
779    }
780
781    #[test]
782    fn allocation_status_defaults_pending() {
783        let s = AllocationStatus::default();
784        assert_eq!(s.phase, AllocationPhase::Pending);
785        assert!(s.bound_pool.is_none());
786        assert!(s.assigned_process.is_none());
787    }
788
789    #[test]
790    fn allocation_phase_round_trips_via_serde() {
791        for p in [
792            AllocationPhase::Pending,
793            AllocationPhase::Queued,
794            AllocationPhase::Bound,
795            AllocationPhase::Releasing,
796            AllocationPhase::Released,
797            AllocationPhase::NoMatchingPool,
798            AllocationPhase::Failed,
799        ] {
800            let s = serde_yaml::to_string(&p).unwrap();
801            let back: AllocationPhase = serde_yaml::from_str(&s).unwrap();
802            assert_eq!(back, p);
803        }
804    }
805
806    // ── closed-set algebra contracts for AllocationPhase
807    //    (ALL × as_str × FromStr × predicate-pair) ────────────────────
808
809    /// `ALL` is the source of truth — pin its closure so a variant
810    /// added without an `ALL` entry fails here via the uniqueness
811    /// check before drifting `FromStr` or the sweep tests below. The
812    /// arity is asserted by the `[Self; 7]` array type itself.
813    ///
814    /// Structural well-formedness of [`AllocationPhase`] as a
815    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
816    /// testkit lift that pins all three structural invariants
817    /// (`ALL` is non-empty, every variant round-trips through
818    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
819    /// outside the closed set) at ONE call site. Replaces the hand-
820    /// derived `allocation_phase_all_is_unique_and_complete` +
821    /// `allocation_phase_roundtrip_via_as_str` + the empty-input arm
822    /// of `unknown_allocation_phase_errors`. `FromStr` delegates to
823    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
824    /// helper exercises the same code path the allocation reconciler
825    /// hits when parsing a CRD `enum:`-validated value back to the
826    /// typed phase.
827    #[test]
828    fn allocation_phase_is_well_formed_closed_set() {
829        tatara_closed_set::assert_closed_set_well_formed::<AllocationPhase>();
830    }
831
832    /// CANONICAL-KEY CONTRACT: `as_str` matches serde's PascalCase
833    /// output verbatim for every variant. A future variant rename
834    /// (or an `as_str` arm typo) lands here at one site, instead of
835    /// drifting between the typed surface, the CRD enum, the YAML
836    /// wire format, and the operator-facing reason strings the
837    /// reconciler stamps via Display.
838    #[test]
839    fn allocation_phase_as_str_matches_serde() {
840        crate::tagged_union::assert_label_matches_serde_serialization::<AllocationPhase>();
841    }
842
843    /// The Display impl IS `as_str` — pinning this lets future
844    /// callers reach for either projection without drift.
845    #[test]
846    fn allocation_phase_display_matches_as_str() {
847        crate::tagged_union::assert_display_matches_label::<AllocationPhase>();
848    }
849
850    /// `FromStr` rejects strings that aren't in the canonical
851    /// projection — lowercased / typo / unrelated — and the error
852    /// echoes the input verbatim so the operator-facing diagnostic
853    /// carries the offending value, not a normalized form. The
854    /// empty-input arm is pinned by
855    /// [`allocation_phase_is_well_formed_closed_set`] via the
856    /// `tatara_lisp::ClosedSet` testkit; the cases here pin the
857    /// verbatim-echo contract on the [`UnknownAllocationPhase`]
858    /// newtype, which the trait's `make_unknown` can't see.
859    #[test]
860    fn unknown_allocation_phase_errors() {
861        for bad in [
862            "pending",
863            "BOUND",
864            "no-matching-pool",
865            "release",
866            "failed_state",
867            "Reaped",
868        ] {
869            let err = AllocationPhase::from_str(bad).unwrap_err();
870            assert_eq!(err.0, bad, "error payload should echo input verbatim");
871        }
872    }
873
874    /// TRUTH-TABLE CONTRACT: the predicate pair agrees with the
875    /// documented per-variant disposition. `Released` + `Failed` are
876    /// terminal (absorbing); `Pending` / `Queued` / `NoMatchingPool`
877    /// need pool routing; `Bound` / `Releasing` are settled-but-not-
878    /// terminal (heartbeat / release ladder).
879    #[test]
880    fn allocation_phase_predicate_truth_tables() {
881        assert!(!AllocationPhase::Pending.is_terminal());
882        assert!(AllocationPhase::Pending.needs_pool_routing());
883
884        assert!(!AllocationPhase::Queued.is_terminal());
885        assert!(AllocationPhase::Queued.needs_pool_routing());
886
887        assert!(!AllocationPhase::Bound.is_terminal());
888        assert!(!AllocationPhase::Bound.needs_pool_routing());
889
890        assert!(!AllocationPhase::Releasing.is_terminal());
891        assert!(!AllocationPhase::Releasing.needs_pool_routing());
892
893        assert!(AllocationPhase::Released.is_terminal());
894        assert!(!AllocationPhase::Released.needs_pool_routing());
895
896        assert!(!AllocationPhase::NoMatchingPool.is_terminal());
897        assert!(AllocationPhase::NoMatchingPool.needs_pool_routing());
898
899        assert!(AllocationPhase::Failed.is_terminal());
900        assert!(!AllocationPhase::Failed.needs_pool_routing());
901    }
902
903    /// IMPLICATION CONTRACT: `is_terminal → !needs_pool_routing`. A
904    /// terminal allocation cannot also be routing-eligible — that's
905    /// the bug the typed projection closes (a `Failed` allocation
906    /// that's neither `Released` nor `Bound` would otherwise slip
907    /// through the open-coded gate in `observe` and try to rebind to
908    /// a pool member). A future variant that flipped both predicates
909    /// true would fail here, forcing the author to flip one or
910    /// extend the consumer dispatch site in
911    /// `tatara-pool-reconciler::allocation_decide` deliberately
912    /// rather than letting an impossible state slip in.
913    #[test]
914    fn allocation_phase_terminal_excludes_routing() {
915        for phase in AllocationPhase::ALL {
916            assert!(
917                !(phase.is_terminal() && phase.needs_pool_routing()),
918                "{phase:?} is both terminal and routing-eligible",
919            );
920        }
921    }
922
923    /// DEFAULT-AGREEMENT CONTRACT: `AllocationPhase::default()` is
924    /// `Pending` — the entry state, neither terminal nor settled —
925    /// and it lives on the routing path. A future default-variant
926    /// rename without flipping the predicates fails here.
927    #[test]
928    fn allocation_phase_default_is_pending_and_routes() {
929        let d = AllocationPhase::default();
930        assert_eq!(d, AllocationPhase::Pending);
931        assert!(!d.is_terminal());
932        assert!(d.needs_pool_routing());
933    }
934
935    // ── RequestorKind closed-set truth-table ─────────────────────────
936
937    /// Structural well-formedness of [`RequestorKind`] as a
938    /// [`tatara_lisp::ClosedSet`] implementor — the workspace-wide
939    /// testkit lift that pins all three structural invariants
940    /// (`ALL` is non-empty, every variant round-trips through
941    /// `label ↔ parse_label`, labels are pairwise distinct, `""` is
942    /// outside the closed set) at ONE call site. Replaces the hand-
943    /// derived `requestor_kind_all_enumerates_each_variant_exactly_once`
944    /// + `requestor_kind_from_str_round_trips_canonical_names` + the
945    /// empty-input arm of `requestor_kind_from_str_rejects_open_kinds`.
946    /// `FromStr` delegates to
947    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this helper
948    /// exercises the same code path
949    /// [`Requestor::known_kind`]'s `Option<RequestorKind>` collapse
950    /// rides on when classifying inbound `Requestor.kind` strings. The
951    /// arity is asserted by the `[Self; 4]` array type itself.
952    #[test]
953    fn requestor_kind_is_well_formed_closed_set() {
954        tatara_closed_set::assert_closed_set_well_formed::<RequestorKind>();
955    }
956
957    /// Byte-exact wire-format pin — renaming any of these is a wire-
958    /// format change (the `tatara-github-watcher` emitter, the CRD
959    /// printcolumns, the `PoolSelector.kinds` filter strings, the
960    /// per-test `kind: "…".into()` fixtures all depend on these
961    /// literals), not a typed-internal refactor.
962    #[test]
963    fn requestor_kind_canonical_names_pinned() {
964        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
965        assert_eq!(RequestorKind::Manual.as_str(), "manual");
966        assert_eq!(RequestorKind::CiRun.as_str(), "ci-run");
967        assert_eq!(RequestorKind::Scheduled.as_str(), "scheduled");
968    }
969
970    /// `FromStr` rejects strings that aren't in the canonical
971    /// projection — lowercased-mismatch / typo / unrelated — and the
972    /// error echoes the input verbatim so the operator-facing
973    /// diagnostic carries the offending value, not a normalized form.
974    /// The schema is open at the wire layer (operators MAY register
975    /// new kinds and `Requestor::known_kind` collapses them to
976    /// `None`), but the closed-set view is byte-exact. The empty-input
977    /// arm is pinned by [`requestor_kind_is_well_formed_closed_set`]
978    /// via the `tatara_lisp::ClosedSet` testkit; the cases here pin
979    /// the verbatim-echo contract on the [`UnknownRequestorKind`]
980    /// newtype, which the trait's `make_unknown` can't see.
981    #[test]
982    fn requestor_kind_from_str_rejects_open_kinds() {
983        for bad in [
984            "github_pr",
985            "GithubPr",
986            "operator-custom-kind",
987            "ci_run",
988            "Scheduled",
989        ] {
990            let err = bad.parse::<RequestorKind>().unwrap_err();
991            assert_eq!(err, UnknownRequestorKind(bad.to_string()));
992        }
993    }
994
995    /// The Display impl IS `as_str` — pinning this lets future
996    /// callers reach for either projection without drift (Display is
997    /// what operator-facing diagnostics compose against).
998    #[test]
999    fn requestor_kind_display_delegates_to_as_str() {
1000        for k in RequestorKind::ALL {
1001            assert_eq!(format!("{k}"), k.as_str());
1002        }
1003    }
1004
1005    /// The `String` projection that `From<RequestorKind> for String`
1006    /// ([`RequestorKind::into`]) composes is byte-equal to `as_str`.
1007    /// This is the typed → wire bridge — emitters spell
1008    /// `kind: RequestorKind::GithubPr.into()` and the canonical
1009    /// literal is materialized at ONE place.
1010    #[test]
1011    fn requestor_kind_into_string_matches_as_str() {
1012        for k in RequestorKind::ALL {
1013            let s: String = k.into();
1014            assert_eq!(s, k.as_str());
1015        }
1016    }
1017
1018    /// The typed → wire → typed round-trip: composing a `Requestor`
1019    /// with `kind: RequestorKind::X.into()` produces an object whose
1020    /// `known_kind()` decodes back to `X`. Pins the bridge invariant
1021    /// at the `Requestor` boundary, not just at `RequestorKind`.
1022    #[test]
1023    fn known_kind_decodes_built_requestors() {
1024        for k in RequestorKind::ALL {
1025            let r = Requestor {
1026                kind: k.into(),
1027                repo: None,
1028                branch: None,
1029                pr_number: None,
1030                sha: None,
1031                pr_labels: vec![],
1032                actor: None,
1033            };
1034            assert_eq!(r.known_kind(), Some(k), "round-trip failed for {k:?}");
1035        }
1036    }
1037
1038    /// Open-by-design: a custom operator-registered kind still
1039    /// stamps a valid `Requestor` (no schema rejection), it just
1040    /// doesn't project through the closed-set typed view. Mirrors
1041    /// `ReceiptEnvelope::known_kind`'s open-kind posture.
1042    #[test]
1043    fn known_kind_returns_none_for_open_kinds() {
1044        let r = Requestor {
1045            kind: "operator-custom-kind".into(),
1046            repo: None,
1047            branch: None,
1048            pr_number: None,
1049            sha: None,
1050            pr_labels: vec![],
1051            actor: None,
1052        };
1053        assert_eq!(r.known_kind(), None);
1054    }
1055
1056    /// The four canonical literals match every previously-published
1057    /// fixture / doc anchor in this crate — pinning the bridge to
1058    /// existing call sites so any drift fails here before the next
1059    /// release ships.
1060    #[test]
1061    fn requestor_kind_matches_existing_fixture_literals() {
1062        // The `requestor_minimum_shape_round_trips` fixture above
1063        // composes `kind: "github-pr".into()` verbatim.
1064        assert_eq!(RequestorKind::GithubPr.as_str(), "github-pr");
1065        // The `allocation_spec_omits_optional_fields` fixture below
1066        // composes `kind: "manual".into()` verbatim.
1067        assert_eq!(RequestorKind::Manual.as_str(), "manual");
1068    }
1069
1070    // Per-implementor `unknown_X_message_matches_substrate_convention`
1071    // tests removed — clause (5) of
1072    // `tatara_closed_set::assert_closed_set_well_formed::<T>()` now verifies
1073    // the substrate-wide `"unknown {SET_LABEL}: {input}"` carrier shape
1074    // generically (called above on `RequestorKind` /
1075    // `AllocationPhase` through their `*_is_well_formed_closed_set`
1076    // sites). The `SET_LABEL` projection is pinned independently by
1077    // `tatara_lisp_derive::pascal_to_spaced_lowercase_tests` —
1078    // together the two contracts guarantee the operator-facing
1079    // diagnostic without needing per-enum literal pins.
1080
1081    // ─── EphemeralAllocation::observed_phase* substrate pins ────────
1082    //
1083    // Fail-before-pass-after granularity: neither `observed_phase` nor
1084    // `observed_phase_or_pending` existed before this commit, so each
1085    // pin fails to compile until the corresponding inherent method
1086    // lands. Post-lift the pins bind the missing-`status` corner + the
1087    // populated-status pass-through + byte-identical parity with the
1088    // pre-lift 5-line `.status.as_ref().map(|s| s.phase).unwrap_or
1089    // (AllocationPhase::Pending)` chain the pool reconciler's
1090    // `AllocationConvergenceCtx::observe` walked. Cross-CRD peer
1091    // coherence with `Process::observed_phase_or_pending` is pinned
1092    // by the `_matches_process_peer_shape` sweep at the tail.
1093
1094    fn alloc_with_phase(phase: AllocationPhase) -> EphemeralAllocation {
1095        let spec = AllocationSpec {
1096            pool_ref: None,
1097            requestor: Requestor {
1098                kind: "manual".into(),
1099                repo: None,
1100                branch: None,
1101                pr_number: None,
1102                sha: None,
1103                pr_labels: vec![],
1104                actor: None,
1105            },
1106            ttl: None,
1107            note: None,
1108        };
1109        let mut a = EphemeralAllocation::new("obs-alloc", spec);
1110        a.status = Some(AllocationStatus {
1111            phase,
1112            ..AllocationStatus::default()
1113        });
1114        a
1115    }
1116
1117    fn alloc_without_status() -> EphemeralAllocation {
1118        let spec = AllocationSpec {
1119            pool_ref: None,
1120            requestor: Requestor {
1121                kind: "manual".into(),
1122                repo: None,
1123                branch: None,
1124                pr_number: None,
1125                sha: None,
1126                pr_labels: vec![],
1127                actor: None,
1128            },
1129            ttl: None,
1130            note: None,
1131        };
1132        let mut a = EphemeralAllocation::new("no-status-alloc", spec);
1133        a.status = None;
1134        a
1135    }
1136
1137    #[test]
1138    fn observed_phase_returns_none_when_status_is_none() {
1139        let a = alloc_without_status();
1140        assert!(a.observed_phase().is_none());
1141    }
1142
1143    #[test]
1144    fn observed_phase_returns_populated_variant_verbatim() {
1145        for p in AllocationPhase::ALL {
1146            let a = alloc_with_phase(p);
1147            assert_eq!(
1148                a.observed_phase(),
1149                Some(p),
1150                "observed_phase must project the persisted variant verbatim for {p:?}"
1151            );
1152        }
1153    }
1154
1155    #[test]
1156    fn observed_phase_matches_pre_lift_chain_bytewise() {
1157        // Sweep every corner: (status: None) plus every populated
1158        // (status: Some(phase)) variant. The pre-lift chain was
1159        // `alloc.status.as_ref().map(|s| s.phase)` — a 3-link chain
1160        // hand-authored inline at the observer. The primitive must
1161        // return the same `Option<AllocationPhase>` on every corner.
1162        let none_alloc = alloc_without_status();
1163        assert_eq!(
1164            none_alloc.observed_phase(),
1165            none_alloc.status.as_ref().map(|s| s.phase),
1166        );
1167        for p in AllocationPhase::ALL {
1168            let a = alloc_with_phase(p);
1169            assert_eq!(
1170                a.observed_phase(),
1171                a.status.as_ref().map(|s| s.phase),
1172                "primitive must be byte-identical to the pre-lift chain for {p:?}",
1173            );
1174        }
1175    }
1176
1177    #[test]
1178    fn observed_phase_or_pending_defaults_to_pending_when_status_absent() {
1179        let a = alloc_without_status();
1180        assert_eq!(a.observed_phase_or_pending(), AllocationPhase::Pending);
1181    }
1182
1183    #[test]
1184    fn observed_phase_or_pending_returns_populated_phase_verbatim() {
1185        for p in AllocationPhase::ALL {
1186            let a = alloc_with_phase(p);
1187            assert_eq!(
1188                a.observed_phase_or_pending(),
1189                p,
1190                "populated status must pass through verbatim for {p:?}"
1191            );
1192        }
1193    }
1194
1195    #[test]
1196    fn observed_phase_or_pending_defaults_agree_with_allocation_phase_default() {
1197        // The `Pending` sink is load-bearing as the "not yet observed"
1198        // default. `AllocationPhase::default()` returns `Pending`; the
1199        // primitive must return the same variant on the missing-status
1200        // corner. A future default-variant rename that flipped
1201        // `AllocationPhase::default` without flipping the primitive
1202        // (or vice versa) surfaces here as a divergent seed for the
1203        // routing ladder.
1204        let a = alloc_without_status();
1205        assert_eq!(a.observed_phase_or_pending(), AllocationPhase::default());
1206    }
1207
1208    #[test]
1209    fn observed_phase_or_pending_matches_pre_lift_chain_bytewise() {
1210        // The exact pre-lift 5-line chain in
1211        // `tatara-pool-reconciler::allocation_decide::
1212        // AllocationConvergenceCtx::observe` was:
1213        //     let phase = alloc
1214        //         .status
1215        //         .as_ref()
1216        //         .map(|s| s.phase)
1217        //         .unwrap_or(AllocationPhase::Pending);
1218        // Sweep every corner: (status: None) plus every populated
1219        // status variant. The primitive must be byte-identical for
1220        // every corner so the observer's routing decision matches
1221        // bytewise post-lift.
1222        let none_alloc = alloc_without_status();
1223        assert_eq!(
1224            none_alloc.observed_phase_or_pending(),
1225            none_alloc
1226                .status
1227                .as_ref()
1228                .map(|s| s.phase)
1229                .unwrap_or(AllocationPhase::Pending),
1230        );
1231        for p in AllocationPhase::ALL {
1232            let a = alloc_with_phase(p);
1233            assert_eq!(
1234                a.observed_phase_or_pending(),
1235                a.status
1236                    .as_ref()
1237                    .map(|s| s.phase)
1238                    .unwrap_or(AllocationPhase::Pending),
1239                "primitive must be byte-identical to the pre-lift 5-line chain for {p:?}",
1240            );
1241        }
1242    }
1243
1244    #[test]
1245    fn observed_phase_or_pending_composes_from_observed_phase() {
1246        // The composer sits on top of the borrow-form projection —
1247        // `observed_phase_or_pending() == observed_phase().unwrap_or
1248        // (Pending)`. Pinning the composition means a future
1249        // normalization step layered onto `observed_phase` (a
1250        // generation-filter, a staleness gate, a canonicalization
1251        // pass) reaches BOTH the raw-`Option` accessor and the
1252        // `Pending`-sinked composer through the SAME upstream body,
1253        // without needing a per-corner rewrite of the composer.
1254        let none_alloc = alloc_without_status();
1255        assert_eq!(
1256            none_alloc.observed_phase_or_pending(),
1257            none_alloc
1258                .observed_phase()
1259                .unwrap_or(AllocationPhase::Pending),
1260        );
1261        for p in AllocationPhase::ALL {
1262            let a = alloc_with_phase(p);
1263            assert_eq!(
1264                a.observed_phase_or_pending(),
1265                a.observed_phase().unwrap_or(AllocationPhase::Pending),
1266                "composer must ride on top of the borrow-form projection for {p:?}",
1267            );
1268        }
1269    }
1270
1271    #[test]
1272    fn observed_phase_is_a_pure_projection() {
1273        // Reading the phase twice must not mutate the allocation or
1274        // its status slot — pure projection semantics. Also witnesses
1275        // that the accessor doesn't clone / drop the inner `phase`
1276        // (the `Copy` scalar comes out identical on both reads).
1277        let a = alloc_with_phase(AllocationPhase::Bound);
1278        let one = a.observed_phase();
1279        let two = a.observed_phase();
1280        assert_eq!(one, two);
1281        assert!(a.status.is_some(), "projection must not consume the status");
1282    }
1283
1284    #[test]
1285    fn observed_phase_pending_missing_status_and_populated_pending_collapse_to_same_composer_output(
1286    ) {
1287        // A subtle correctness pin: the missing-`status` corner and
1288        // a populated-with-Pending status BOTH read as `Pending`
1289        // through the composer — the observer cannot distinguish the
1290        // two through this accessor. This matches the pre-lift 5-line
1291        // chain's semantics exactly (an operator patching
1292        // `status.phase: Pending` is indistinguishable from a
1293        // freshly-admitted allocation with no status stamped yet).
1294        // The borrow-form `observed_phase` accessor DOES distinguish
1295        // the two, so a caller that needs to tell them apart reaches
1296        // for the raw `Option`.
1297        let none_alloc = alloc_without_status();
1298        let pending_alloc = alloc_with_phase(AllocationPhase::Pending);
1299
1300        assert_eq!(
1301            none_alloc.observed_phase_or_pending(),
1302            pending_alloc.observed_phase_or_pending(),
1303        );
1304        assert_ne!(
1305            none_alloc.observed_phase(),
1306            pending_alloc.observed_phase(),
1307            "borrow-form accessor MUST distinguish missing-status from populated-Pending",
1308        );
1309    }
1310
1311    #[test]
1312    fn observed_phase_or_pending_missing_status_sink_agrees_with_process_peer_shape() {
1313        // Cross-CRD peer-axis coherence with
1314        // `Process::observed_phase_or_pending`. Both primitives walk
1315        // the identical `.status.as_ref().map(|s| s.phase).unwrap_or
1316        // (<Phase>::Pending)` chain differing ONLY in the `Phase`
1317        // type projected. On a missing-status observation, each
1318        // primitive must return its CRD's `Default`-equivalent
1319        // `Pending` variant — for `EphemeralAllocation` that's
1320        // `AllocationPhase::Pending`; for `Process` that's
1321        // `crate::phase::ProcessPhase::Pending`. This pin binds the
1322        // sink-parity structurally so a future rename of either
1323        // default variant surfaces here as a divergent seed for the
1324        // observer's routing / dispatch decision rather than as
1325        // silent drift between the two reconcilers.
1326        let no_status_alloc = alloc_without_status();
1327        assert_eq!(
1328            no_status_alloc.observed_phase_or_pending(),
1329            AllocationPhase::default(),
1330        );
1331        // Peer-axis invariant on the `Process` side — the primitive
1332        // that owns the same shape reads `ProcessPhase::Pending` on
1333        // the missing-status corner via its own inherent method. The
1334        // parity is coordinated at the `Default` seat: both CRDs'
1335        // phase types default to `Pending`, so a rename that broke
1336        // one without the other would fail one of these two
1337        // conjoined assertions.
1338        assert_eq!(AllocationPhase::default(), AllocationPhase::Pending,);
1339        assert_eq!(
1340            crate::phase::ProcessPhase::default(),
1341            crate::phase::ProcessPhase::Pending,
1342        );
1343    }
1344
1345    // ─── EphemeralAllocation::observed_bound_pool substrate pins ────
1346    //
1347    // The borrow-form status-projection primitive on the bound-pool
1348    // axis. Collapses the pre-lift hand-authored `.status.as_ref()
1349    // .and_then(|s| s.bound_pool.clone())` chain in
1350    // `tatara-pool-reconciler::allocation_decide::
1351    // AllocationConvergenceCtx::observe`'s `bound_pool` seed onto the
1352    // ONE substrate primitive. Cross-CRD peer to
1353    // `Process::observed_identity` on the (CRD × structured-record-
1354    // slot × borrow-form) axis pair — both primitives walk the
1355    // identical `.status.as_ref().and_then(|s| s.<slot>.as_ref())`
1356    // shape. Each pin is fail-before-pass-after: `observed_bound_pool`
1357    // did not exist pre-lift, so any test invoking it fails to compile
1358    // pre-lift and passes post-lift.
1359
1360    fn sample_pool_ref(name: &str, ns: &str) -> AllocationRef {
1361        AllocationRef {
1362            name: name.to_string(),
1363            namespace: ns.to_string(),
1364        }
1365    }
1366
1367    fn alloc_with_bound_pool(bound: Option<AllocationRef>) -> EphemeralAllocation {
1368        let spec = AllocationSpec {
1369            pool_ref: None,
1370            requestor: Requestor {
1371                kind: "manual".into(),
1372                repo: None,
1373                branch: None,
1374                pr_number: None,
1375                sha: None,
1376                pr_labels: vec![],
1377                actor: None,
1378            },
1379            ttl: None,
1380            note: None,
1381        };
1382        let mut a = EphemeralAllocation::new("bp-alloc", spec);
1383        a.status = Some(AllocationStatus {
1384            phase: AllocationPhase::Bound,
1385            bound_pool: bound,
1386            ..AllocationStatus::default()
1387        });
1388        a
1389    }
1390
1391    #[test]
1392    fn observed_bound_pool_returns_none_when_status_is_none() {
1393        // Missing-`status` corner pin: the primitive collapses the
1394        // no-status case to `None` so downstream `.is_some()` /
1395        // `if let Some(_)` / `.cloned().unwrap_or_else(...)` behave
1396        // identically on an `EphemeralAllocation` whose status field
1397        // is `None` and on one whose status carries an unpopulated
1398        // `bound_pool` slot. Matches the pre-lift `.and_then(...)`
1399        // chain's `None` byte-identically at the pool reconciler's
1400        // Release-composition seed.
1401        let a = alloc_without_status();
1402        assert!(a.observed_bound_pool().is_none());
1403    }
1404
1405    #[test]
1406    fn observed_bound_pool_returns_none_when_slot_is_none() {
1407        // Empty-slot-under-populated-status corner pin: the primitive
1408        // returns `None`, matching the missing-`status` corner byte-
1409        // identically. A regression that treated the two corners
1410        // differently would silently promote an internal representation
1411        // detail (whether the pool reconciler has ever written a
1412        // status subresource) into observable behavior at the
1413        // Release-composition branch of the allocation reconciler's
1414        // `decide` transition rule.
1415        let a = alloc_with_bound_pool(None);
1416        assert!(a.observed_bound_pool().is_none());
1417    }
1418
1419    #[test]
1420    fn observed_bound_pool_returns_borrow_when_slot_is_populated() {
1421        // Happy-path pin: with a populated `status.bound_pool` slot,
1422        // the primitive returns a borrowed `&AllocationRef` whose
1423        // (name, namespace) fields match the persisted record. A
1424        // regression that filtered / reshaped / canonicalized the
1425        // record would surface here rather than as silent skew at the
1426        // Release-composition seed's `.cloned()` materialization.
1427        let expected = sample_pool_ref("demo-pool", "pools");
1428        let a = alloc_with_bound_pool(Some(expected.clone()));
1429        let observed = a.observed_bound_pool().expect("populated slot");
1430        assert_eq!(observed, &expected);
1431        assert_eq!(observed.name, "demo-pool");
1432        assert_eq!(observed.namespace, "pools");
1433    }
1434
1435    #[test]
1436    fn observed_bound_pool_is_a_zero_copy_borrow_projection() {
1437        // Borrow-discipline pin: the returned reference points at the
1438        // persisted `AllocationRef` in place — NOT a fresh allocation
1439        // or a clone. A regression that switched the projection to an
1440        // owned `AllocationRef` (via `.clone()`) would defeat the
1441        // zero-copy contract the lift's primary strict-widening
1442        // delivers (the observer's Release-composition arm clones
1443        // once at the composition point where the
1444        // `AllocationConvergenceCtx` snapshot slot requires the owned
1445        // value). Peer to the sibling
1446        // `Process::observed_identity_is_a_zero_copy_borrow_projection`
1447        // pin on the `Process` CRD's `status.identity` slot.
1448        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1449        let observed = a.observed_bound_pool().expect("populated slot") as *const _;
1450        let persisted = a.status.as_ref().unwrap().bound_pool.as_ref().unwrap() as *const _;
1451        assert!(std::ptr::eq(observed, persisted));
1452    }
1453
1454    #[test]
1455    fn observed_bound_pool_is_a_pure_projection() {
1456        // Purity pin: calling the projection twice on the same
1457        // `EphemeralAllocation` returns byte-identical borrows (same
1458        // pointer). A regression that introduced state — a lazy-
1459        // cached reference, a normalization step that ran once and
1460        // cached — would surface here rather than as silent drift
1461        // between two dispatches within one reconcile pass.
1462        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1463        let one = a.observed_bound_pool().expect("populated slot") as *const _;
1464        let two = a.observed_bound_pool().expect("populated slot") as *const _;
1465        assert!(std::ptr::eq(one, two));
1466    }
1467
1468    #[test]
1469    fn observed_bound_pool_matches_pre_lift_chain_bytewise() {
1470        // Byte-identical parity pin between the borrow-form primitive
1471        // here and the pre-lift `tatara-pool-reconciler`
1472        // `.status.as_ref().and_then(|s| s.bound_pool.clone())` chain.
1473        // Sweeps every corner every callsite plausibly encounters
1474        // (missing status, empty `bound_pool` slot, populated
1475        // `bound_pool` slot). A regression that inserted a
1476        // normalization step at the primitive the pre-lift chain does
1477        // NOT apply — or vice versa — surfaces here rather than as
1478        // silent drift between the pre-lift consumer site and the ONE
1479        // substrate owner it now routes through.
1480        fn pre_lift(a: &EphemeralAllocation) -> Option<AllocationRef> {
1481            a.status.as_ref().and_then(|s| s.bound_pool.clone())
1482        }
1483        // Missing status.
1484        let a = alloc_without_status();
1485        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1486        // Populated status, empty `bound_pool` slot.
1487        let a = alloc_with_bound_pool(None);
1488        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1489        // Populated status, populated `bound_pool` slot.
1490        let a = alloc_with_bound_pool(Some(sample_pool_ref("demo-pool", "pools")));
1491        assert_eq!(a.observed_bound_pool().cloned(), pre_lift(&a));
1492    }
1493
1494    #[test]
1495    fn observed_bound_pool_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
1496        // Cross-corner coherence pin: the missing-`status` corner and
1497        // the populated-empty-slot corner return `Option`s whose
1498        // `.is_none()` / `.is_some()` observations are IDENTICAL. A
1499        // regression that promoted the missing-`status` corner to a
1500        // typed error (via a signature change to `Result<_, _>`) — or
1501        // that widened the empty-slot corner to a synthetic
1502        // `Some(AllocationRef::default())` — would surface here rather
1503        // than as silent operator-facing divergence between a never-
1504        // status-written allocation and a bound-pool-cleared
1505        // allocation on the Release-composition branch.
1506        let a_no_status = alloc_without_status();
1507        let a_empty_slot = alloc_with_bound_pool(None);
1508        assert_eq!(
1509            a_no_status.observed_bound_pool().is_none(),
1510            a_empty_slot.observed_bound_pool().is_none(),
1511        );
1512        assert_eq!(
1513            a_no_status.observed_bound_pool().is_some(),
1514            a_empty_slot.observed_bound_pool().is_some(),
1515        );
1516    }
1517
1518    #[test]
1519    fn observed_bound_pool_shape_agrees_with_process_observed_identity_peer_axis() {
1520        // Cross-CRD peer-axis coherence pin binding the SAME
1521        // `.status.as_ref().and_then(|s| s.<slot>.as_ref())` shape
1522        // that both `EphemeralAllocation::observed_bound_pool` (this
1523        // primitive) and `Process::observed_identity` walk, differing
1524        // ONLY in the record projected. Structural test — both
1525        // signatures must resolve as `&Self -> Option<&Record>` fn
1526        // pointers, so a future rename or a signature drift that
1527        // (say) widened one side to `Option<Record>` or narrowed one
1528        // side to `Option<&str>` fails to compile here rather than
1529        // silently drifting the two reconcilers apart at their
1530        // respective observer seeds. The runtime side of the pin
1531        // sweeps the missing-status + empty-slot corners on the
1532        // `EphemeralAllocation` half; the `Process` half is exercised
1533        // by its own `crd.rs::tests::observed_identity_*` pin
1534        // family — this test binds only the peer-axis shape.
1535        let a_no_status = alloc_without_status();
1536        let a_empty_slot = alloc_with_bound_pool(None);
1537        assert!(a_no_status.observed_bound_pool().is_none());
1538        assert!(a_empty_slot.observed_bound_pool().is_none());
1539        // Structural peer-axis coherence: bind both signatures as fn
1540        // pointers at their peer resolution type so the compiler
1541        // refuses to build if either side's shape drifts. The `_`
1542        // let-bindings assert the target type inference.
1543        let _bound_pool_shape: fn(&EphemeralAllocation) -> Option<&AllocationRef> =
1544            EphemeralAllocation::observed_bound_pool;
1545        let _identity_shape: fn(&crate::prelude::Process) -> Option<&crate::identity::Identity> =
1546            crate::prelude::Process::observed_identity;
1547    }
1548
1549    // ─── EphemeralAllocation::observed_expires_at substrate pins ────
1550    //
1551    // The copy-form status-projection primitive on the TTL-expiry axis.
1552    // Collapses the pre-lift hand-authored `.status.as_ref().and_then(
1553    // |s| s.expires_at)` chain in `tatara-pool-reconciler::
1554    // allocation_decide::AllocationConvergenceCtx::observe`'s
1555    // `expires_at` seed onto the ONE substrate primitive. Same-CRD peer
1556    // to `observed_phase` on the (copy-form × status-slot) axis — both
1557    // primitives walk the identical `.status.as_ref().<map|and_then>(
1558    // |s| s.<Copy-field>)` shape. Each pin is fail-before-pass-after:
1559    // `observed_expires_at` did not exist pre-lift, so any test invoking
1560    // it fails to compile pre-lift and passes post-lift.
1561
1562    fn alloc_with_expires_at(expires_at: Option<DateTime<Utc>>) -> EphemeralAllocation {
1563        let spec = AllocationSpec {
1564            pool_ref: None,
1565            requestor: Requestor {
1566                kind: "manual".into(),
1567                repo: None,
1568                branch: None,
1569                pr_number: None,
1570                sha: None,
1571                pr_labels: vec![],
1572                actor: None,
1573            },
1574            ttl: None,
1575            note: None,
1576        };
1577        let mut a = EphemeralAllocation::new("exp-alloc", spec);
1578        a.status = Some(AllocationStatus {
1579            phase: AllocationPhase::Bound,
1580            expires_at,
1581            ..AllocationStatus::default()
1582        });
1583        a
1584    }
1585
1586    #[test]
1587    fn observed_expires_at_returns_none_when_status_is_none() {
1588        // Missing-`status` corner pin: the primitive collapses the
1589        // no-status case to `None` so downstream `.is_some()` / any
1590        // deadline comparison behaves identically on an
1591        // `EphemeralAllocation` whose status field is `None` and on
1592        // one whose status carries an unpopulated `expires_at` slot.
1593        // Matches the pre-lift `.and_then(...)` chain's `None` byte-
1594        // identically at the pool reconciler's Release-composition
1595        // TTL gate.
1596        let a = alloc_without_status();
1597        assert!(a.observed_expires_at().is_none());
1598    }
1599
1600    #[test]
1601    fn observed_expires_at_returns_none_when_slot_is_none() {
1602        // Empty-slot-under-populated-status corner pin: the primitive
1603        // returns `None`, matching the missing-`status` corner byte-
1604        // identically. A regression that treated the two corners
1605        // differently would silently promote an internal representation
1606        // detail (whether the pool reconciler has ever written a
1607        // `status.expires_at` field for a not-yet-Bound allocation)
1608        // into observable behavior at the Release-composition branch
1609        // of the allocation reconciler's `decide` transition rule.
1610        let a = alloc_with_expires_at(None);
1611        assert!(a.observed_expires_at().is_none());
1612    }
1613
1614    #[test]
1615    fn observed_expires_at_returns_populated_timestamp_verbatim() {
1616        // Happy-path pin: with a populated `status.expires_at` slot,
1617        // the primitive returns the persisted `DateTime<Utc>` verbatim.
1618        // A regression that filtered / clamped / canonicalized the
1619        // timestamp would surface here rather than as silent skew at
1620        // the Release-composition TTL gate's `>=` deadline comparison.
1621        let expected = Utc::now();
1622        let a = alloc_with_expires_at(Some(expected));
1623        assert_eq!(a.observed_expires_at(), Some(expected));
1624    }
1625
1626    #[test]
1627    fn observed_expires_at_is_a_pure_projection() {
1628        // Purity pin: calling the projection twice on the same
1629        // `EphemeralAllocation` returns byte-identical `Option`s. A
1630        // regression that introduced state — a lazy-cached value, a
1631        // normalization step that ran once and cached — would surface
1632        // here rather than as silent drift between two dispatches
1633        // within one reconcile pass.
1634        let expected = Utc::now();
1635        let a = alloc_with_expires_at(Some(expected));
1636        assert_eq!(a.observed_expires_at(), a.observed_expires_at());
1637    }
1638
1639    #[test]
1640    fn observed_expires_at_matches_pre_lift_chain_bytewise() {
1641        // Byte-identical parity pin between the copy-form primitive
1642        // here and the pre-lift `tatara-pool-reconciler`
1643        // `.status.as_ref().and_then(|s| s.expires_at)` chain. Sweeps
1644        // every corner every callsite plausibly encounters (missing
1645        // status, empty `expires_at` slot, populated `expires_at`
1646        // slot). A regression that inserted a normalization step at
1647        // the primitive the pre-lift chain does NOT apply — or vice
1648        // versa — surfaces here rather than as silent drift between
1649        // the pre-lift consumer site and the ONE substrate owner it
1650        // now routes through.
1651        fn pre_lift(a: &EphemeralAllocation) -> Option<DateTime<Utc>> {
1652            a.status.as_ref().and_then(|s| s.expires_at)
1653        }
1654        // Missing status.
1655        let a = alloc_without_status();
1656        assert_eq!(a.observed_expires_at(), pre_lift(&a));
1657        // Populated status, empty `expires_at` slot.
1658        let a = alloc_with_expires_at(None);
1659        assert_eq!(a.observed_expires_at(), pre_lift(&a));
1660        // Populated status, populated `expires_at` slot.
1661        let a = alloc_with_expires_at(Some(Utc::now()));
1662        assert_eq!(a.observed_expires_at(), pre_lift(&a));
1663    }
1664
1665    #[test]
1666    fn observed_expires_at_missing_status_and_empty_slot_collapse_to_the_same_option_shape() {
1667        // Cross-corner coherence pin: the missing-`status` corner and
1668        // the populated-empty-slot corner return `Option`s whose
1669        // `.is_none()` / `.is_some()` observations are IDENTICAL. A
1670        // regression that promoted the missing-`status` corner to a
1671        // typed error (via a signature change to `Result<_, _>`) — or
1672        // that widened the empty-slot corner to a synthetic
1673        // `Some(Utc::now())` — would surface here rather than as
1674        // silent operator-facing divergence between a never-status-
1675        // written allocation and a Bind-time-without-TTL allocation on
1676        // the Release-composition branch.
1677        let a_no_status = alloc_without_status();
1678        let a_empty_slot = alloc_with_expires_at(None);
1679        assert_eq!(
1680            a_no_status.observed_expires_at().is_none(),
1681            a_empty_slot.observed_expires_at().is_none(),
1682        );
1683        assert_eq!(
1684            a_no_status.observed_expires_at().is_some(),
1685            a_empty_slot.observed_expires_at().is_some(),
1686        );
1687    }
1688
1689    #[test]
1690    fn observed_expires_at_shape_agrees_with_observed_phase_peer_axis() {
1691        // Same-CRD peer-axis coherence pin binding the SAME
1692        // `.status.as_ref().<map|and_then>(|s| s.<Copy-field>)` shape
1693        // that both `EphemeralAllocation::observed_expires_at` (this
1694        // primitive) and `EphemeralAllocation::observed_phase` walk,
1695        // differing only in the outer combinator (`and_then` here
1696        // because the persisted field is itself `Option<T>`, `map`
1697        // there because the persisted phase is bare) and in the
1698        // projected `Copy` type. Structural test — both signatures
1699        // must resolve as `&Self -> Option<T>` fn pointers with `T`
1700        // `Copy`, so a future rename or a signature drift that (say)
1701        // widened one side to `Option<&T>` or narrowed one side to
1702        // `T` fails to compile here rather than silently drifting
1703        // the family apart. The runtime side of the pin sweeps the
1704        // missing-status + empty-slot corners on the `expires_at`
1705        // half; the `phase` half is exercised by its own
1706        // `tests::observed_phase_*` pin family — this test binds
1707        // only the peer-axis shape.
1708        let a_no_status = alloc_without_status();
1709        let a_empty_slot = alloc_with_expires_at(None);
1710        assert!(a_no_status.observed_expires_at().is_none());
1711        assert!(a_empty_slot.observed_expires_at().is_none());
1712        // Structural peer-axis coherence: bind both signatures as fn
1713        // pointers at their peer resolution type so the compiler
1714        // refuses to build if either side's shape drifts.
1715        let _expires_at_shape: fn(&EphemeralAllocation) -> Option<DateTime<Utc>> =
1716            EphemeralAllocation::observed_expires_at;
1717        let _phase_shape: fn(&EphemeralAllocation) -> Option<AllocationPhase> =
1718            EphemeralAllocation::observed_phase;
1719    }
1720
1721    #[test]
1722    fn allocation_spec_omits_optional_fields() {
1723        let s = AllocationSpec {
1724            pool_ref: None,
1725            requestor: Requestor {
1726                kind: "manual".into(),
1727                repo: None,
1728                branch: None,
1729                pr_number: None,
1730                sha: None,
1731                pr_labels: vec![],
1732                actor: None,
1733            },
1734            ttl: None,
1735            note: None,
1736        };
1737        let yaml = serde_yaml::to_string(&s).unwrap();
1738        assert!(!yaml.contains("poolRef"));
1739        assert!(!yaml.contains("ttl"));
1740        assert!(!yaml.contains("note"));
1741    }
1742}