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}