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