Skip to main content

tatara_process/
crd.rs

1//! The `Process` CRD — `tatara.pleme.io/v1alpha1`.
2
3use chrono::{DateTime, Utc};
4use kube::CustomResource;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use tatara_lisp::DeriveTataraDomain;
8
9use crate::attestation::ProcessAttestation;
10use crate::boundary::Boundary;
11use crate::classification::Classification;
12use crate::compliance::ComplianceSpec;
13use crate::encapsulates::EncapsulatesSpec;
14use crate::identity::Identity;
15use crate::intent::Intent;
16use crate::lifetime::Lifetime;
17use crate::phase::ProcessPhase;
18use crate::routing::RoutingSpec;
19use crate::signal::ProcessSignal;
20use crate::spec::{DependsOn, IdentitySpec, SignalPolicy};
21use crate::status::{BoundaryStatus, ComplianceStatus, FluxResourceRef, ProcessCondition};
22
23/// Process — one element of the tatara convergence lattice, reconciled as a Unix process.
24///
25/// ```yaml
26/// apiVersion: tatara.pleme.io/v1alpha1
27/// kind: Process
28/// metadata:
29///   name: observability-stack
30///   namespace: seph
31/// spec:
32///   identity:
33///     parent: seph.1
34///   classification:
35///     pointType: Gate
36///     substrate: Observability
37///   intent:
38///     nix:
39///       flakeRef: github:pleme-io/k8s?dir=shared/infrastructure
40///       attribute: observability
41///   compliance:
42///     baseline: fedramp-moderate
43///     bindings:
44///       - framework: nist-800-53
45///         controlId: SC-7
46///         phase: AtBoundary
47///   dependsOn:
48///     - name: secret-injection
49/// ```
50#[derive(CustomResource, DeriveTataraDomain, Clone, Debug, Deserialize, Serialize, JsonSchema)]
51#[kube(
52    group = "tatara.pleme.io",
53    version = "v1alpha1",
54    kind = "Process",
55    plural = "processes",
56    shortname = "proc",
57    namespaced,
58    status = "ProcessStatus",
59    printcolumn = r#"{"name":"PID","type":"string","jsonPath":".status.pid"}"#,
60    printcolumn = r#"{"name":"Phase","type":"string","jsonPath":".status.phase"}"#,
61    printcolumn = r#"{"name":"Type","type":"string","jsonPath":".spec.classification.pointType"}"#,
62    printcolumn = r#"{"name":"Substrate","type":"string","jsonPath":".spec.classification.substrate"}"#,
63    printcolumn = r#"{"name":"Gen","type":"integer","jsonPath":".status.attestation.generation"}"#,
64    printcolumn = r#"{"name":"Age","type":"date","jsonPath":".metadata.creationTimestamp"}"#
65)]
66#[serde(rename_all = "camelCase")]
67#[tatara(keyword = "defpoint")]
68pub struct ProcessSpec {
69    /// Identity (parent, name override).
70    #[serde(default)]
71    pub identity: IdentitySpec,
72
73    /// Lattice position (6 dimensions).
74    pub classification: Classification,
75
76    /// Where rendered artifacts come from. Exactly one variant must be set.
77    pub intent: Intent,
78
79    /// Boundary predicates (preconditions / postconditions).
80    #[serde(default)]
81    pub boundary: Boundary,
82
83    /// Compliance bindings + baseline.
84    #[serde(default)]
85    pub compliance: ComplianceSpec,
86
87    /// Lattice dependencies — must reach phase before we proceed.
88    #[serde(default)]
89    pub depends_on: Vec<DependsOn>,
90
91    /// Signal policy (grace, SIGHUP strategy, start-suspended).
92    #[serde(default)]
93    pub signals: SignalPolicy,
94
95    /// Lifetime — `Permanent` (default, re-converging) or `Ephemeral`
96    /// (auto-SIGTERM per `teardown_policy` + TTL clock).
97    #[serde(default, skip_serializing_if = "Lifetime::is_default")]
98    pub lifetime: Lifetime,
99
100    /// External edges — DNS + Ingress. When `None`, the Process is
101    /// internal-only (matches today's default). See
102    /// [`crate::routing`] for the full shape.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub routing: Option<RoutingSpec>,
105
106    /// Pre-existing in-cluster state this Process wraps. When `None`,
107    /// the Process is greenfield (Manage mode implicitly applied to
108    /// nothing pre-existing). See [`crate::encapsulates`] for the
109    /// three modes (Manage / Adopt / Observe).
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub encapsulates: Option<EncapsulatesSpec>,
112
113    /// Soft-suspend marker — reconciler treats as SIGSTOP.
114    /// Same effect as delivering SIGSTOP, but persistent across restarts.
115    #[serde(default)]
116    pub suspended: bool,
117}
118
119// Coordinate primitives — the `(namespace, name)` pair every downstream
120// composer (annotation writers, claim arbiter, boundary evaluator,
121// render owner-metadata seed) pulled by hand from `Process.metadata`
122// pre-lift, each restating the same two `Option<String>`-to-`&str`
123// unwrap incantations with the same two workspace-wide fallback
124// strings sprayed inline. Post-lift the pair lives at ONE substrate
125// primitive on `Process` — a future normalization (case-fold,
126// unicode-safe collation, cross-cluster prefix, a rename of either
127// fallback) lands here and every downstream composer inherits the
128// upgrade mechanically. Peer to `qualified_process_ref` in
129// `tatara-reconciler::ssapply`, whose two `&str` arguments are
130// exactly the pair `Process::coordinates_or_defaults` returns.
131impl Process {
132    /// The K8s canonical default namespace — the fallback every
133    /// consumer of a `Process` whose `metadata.namespace` is `None`
134    /// substitutes. Matches the string K8s itself substitutes on
135    /// namespaced resource writes with no explicit namespace.
136    pub const DEFAULT_NAMESPACE: &'static str = "default";
137
138    /// Workspace-wide fallback for a `Process`'s `metadata.name` when
139    /// it is `None` — the sentinel every annotation writer, claim
140    /// arbiter, and owner-metadata seed substitutes so downstream
141    /// grepping / label-selecting sees a stable spelling rather than
142    /// a per-callsite ad-hoc placeholder (`""`, `"<unnamed>"`, or the
143    /// empty `unwrap_or_default()` fallback). A Process authored
144    /// through the reconciler's fork path always has a name; this
145    /// constant covers the surface where an untyped `Process` value
146    /// (test fixture, dynamic API response, adopted resource pre-
147    /// name-resolution) surfaces without one.
148    pub const UNNAMED_PLACEHOLDER: &'static str = "unnamed";
149
150    /// Namespace slice with the [`Self::DEFAULT_NAMESPACE`] fallback
151    /// applied — the ONE-line collapse of the `metadata.namespace
152    /// .as_deref().unwrap_or("default")` incantation every consumer
153    /// spelled by hand pre-lift.
154    ///
155    /// Peer to [`Self::name_or_placeholder`] on the (metadata slot ×
156    /// fallback shape) axis; both compose through
157    /// [`Self::coordinates_or_defaults`] when a consumer needs the
158    /// pair together (annotation writers, claim-arbiter row builders,
159    /// render owner-metadata seed).
160    pub fn namespace_or_default(&self) -> &str {
161        self.metadata
162            .namespace
163            .as_deref()
164            .unwrap_or(Self::DEFAULT_NAMESPACE)
165    }
166
167    /// Name slice with the [`Self::UNNAMED_PLACEHOLDER`] fallback
168    /// applied — the ONE-line collapse of the `metadata.name.as_deref
169    /// ().unwrap_or("unnamed")` incantation every consumer spelled by
170    /// hand pre-lift.
171    ///
172    /// Peer to [`Self::namespace_or_default`] on the (metadata slot ×
173    /// fallback shape) axis; both compose through
174    /// [`Self::coordinates_or_defaults`] when a consumer needs the
175    /// pair together.
176    pub fn name_or_placeholder(&self) -> &str {
177        self.metadata
178            .name
179            .as_deref()
180            .unwrap_or(Self::UNNAMED_PLACEHOLDER)
181    }
182
183    /// `(namespace, name)` coordinates with the workspace-wide default
184    /// fallbacks applied — the ONE-line collapse of the paired
185    /// `metadata.namespace.as_deref().unwrap_or("default")` +
186    /// `metadata.name.as_deref().unwrap_or("unnamed")` extraction
187    /// every downstream composer restated by hand pre-lift.
188    ///
189    /// Return-tuple order matches the axis order of the substrate's
190    /// paired-composer primitive
191    /// `tatara_reconciler::ssapply::qualified_process_ref(ns, name)`:
192    /// the (namespace, name) pair this method returns feeds that
193    /// primitive positionally without an axis-swap step.
194    pub fn coordinates_or_defaults(&self) -> (&str, &str) {
195        (self.namespace_or_default(), self.name_or_placeholder())
196    }
197
198    /// `(namespace, name)` coordinates as owned `String`s, with the
199    /// namespace half fallback-defaulted to [`Self::DEFAULT_NAMESPACE`]
200    /// but the name half REQUIRED — an [`anyhow::Error`] is returned
201    /// when `metadata.name` is absent, because "unnamed" is a display
202    /// placeholder, not a valid K8s API path segment. Fed straight into
203    /// kube-rs API calls (`Api::patch`, `Api::delete`, `Api::get`) that
204    /// take owned `String` arguments; the [`Self::DEFAULT_NAMESPACE`]
205    /// fallback matches what K8s itself substitutes on namespaced
206    /// resource writes with no explicit namespace, so the surface is
207    /// safe against a `Process` whose `metadata.namespace` slot is
208    /// absent (test fixture, dynamic API response pre-defaulting) but
209    /// refuses to guess a name.
210    ///
211    /// Peer to [`Self::coordinates_or_defaults`] on the (return-form ×
212    /// name gate) axis pair:
213    /// * borrow + name-defaulted → `coordinates_or_defaults` (display,
214    ///   annotation writers, ownership-tag composers — every consumer
215    ///   whose downstream drops `"unnamed"` in place of a missing name
216    ///   without an operator-visible failure);
217    /// * owned + name-required → this method (kube-rs API calls —
218    ///   every consumer whose downstream must NOT silently substitute
219    ///   a placeholder for the API call target, because the caller is
220    ///   about to `patch`/`delete`/`get` at `metadata.name`).
221    ///
222    /// The error wording is pinned by
223    /// [`tests::owned_coordinates_or_err_error_message_matches_pre_lift_reconciler_wording`]
224    /// to match the exact spelling every pre-lift `tatara-reconciler`
225    /// helper produced (`"Process has no metadata.name"`) so log-line
226    /// / test greps that anchored on that wording keep matching post-
227    /// lift, and no operator-visible message drift lands as a side
228    /// effect of the substrate move.
229    pub fn owned_coordinates_or_err(&self) -> anyhow::Result<(String, String)> {
230        let ns = self
231            .metadata
232            .namespace
233            .clone()
234            .unwrap_or_else(|| Self::DEFAULT_NAMESPACE.into());
235        let name = self
236            .metadata
237            .name
238            .clone()
239            .ok_or_else(|| anyhow::anyhow!("Process has no metadata.name"))?;
240        Ok((ns, name))
241    }
242
243    /// `(namespace, name)` coordinates in the BORROW + NAME-REQUIRED
244    /// corner of the primitive family — namespace half falls back to
245    /// [`Self::DEFAULT_NAMESPACE`], but the name half is REQUIRED
246    /// (`None` on a `Process` whose `metadata.name` is absent, so the
247    /// caller stops with an `else { continue; }` / `else { return
248    /// …; }` guard rather than proceeding with the empty-string
249    /// sentinel every pre-lift consumer had to spell inline).
250    ///
251    /// Peer to [`Self::coordinates_or_defaults`] +
252    /// [`Self::owned_coordinates_or_err`] on the (return-form ×
253    /// name-gate) axis pair — closes the corner the family previously
254    /// left open:
255    ///
256    /// * borrow + name-defaulted → [`Self::coordinates_or_defaults`]
257    ///   (annotation writers, render owner-metadata seed — consumers
258    ///   whose downstream tolerates the `"unnamed"` display placeholder
259    ///   without operator-visible failure);
260    /// * borrow + name-required → **this method** (claim-arbiter
261    ///   probes, child-Process delete-fan-out — consumers that need a
262    ///   real API-path leaf and cleanly SKIP the row when the name is
263    ///   absent rather than issuing a K8s call with an empty-string
264    ///   name argument);
265    /// * owned + name-required → [`Self::owned_coordinates_or_err`]
266    ///   (kube-rs API-path calls — consumers whose downstream requires
267    ///   owned `String` arguments and rejects the missing-name corner
268    ///   with a load-bearing error message).
269    ///
270    /// The primitive family's `None`-on-missing-name semantics
271    /// intentionally differs from [`Self::owned_coordinates_or_err`]'s
272    /// error-on-missing-name semantics: the caller sites for this form
273    /// (child-Process fan-out, claim-arbiter row probes) are non-fatal
274    /// SKIPS rather than reportable failures — an `Option::None` at
275    /// the primitive lets the caller thread that "skip" through a
276    /// let-else without stringifying / logging an anyhow chain per
277    /// missing-name occurrence.
278    ///
279    /// The namespace fallback matches [`Self::coordinates_or_defaults`]
280    /// (via [`Self::namespace_or_default`]), so a consumer that
281    /// switches between the two borrow-form primitives based on its
282    /// name-gate need never sees a different namespace-fallback string
283    /// as a side effect.
284    pub fn coordinates_or_none(&self) -> Option<(&str, &str)> {
285        let name = self.metadata.name.as_deref()?;
286        Some((self.namespace_or_default(), name))
287    }
288}
289
290/// Process status — every field optional until the reconciler writes it.
291#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
292#[serde(rename_all = "camelCase")]
293pub struct ProcessStatus {
294    /// Hierarchical PID path — e.g., `"seph.1.7"`.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub pid: Option<String>,
297
298    /// Parent PID path (mirror of `spec.identity.parent`, resolved at fork).
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub parent: Option<String>,
301
302    /// Direct children's PID paths.
303    #[serde(default)]
304    pub children: Vec<String>,
305
306    /// Resolved identity (name + content hash).
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub identity: Option<Identity>,
309
310    /// Current phase.
311    #[serde(default)]
312    pub phase: ProcessPhase,
313
314    /// When the process entered the current phase.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub phase_since: Option<DateTime<Utc>>,
317
318    /// Three-pillar attestation (written at end of every successful cycle).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub attestation: Option<ProcessAttestation>,
321
322    /// FluxCD resources currently owned by this Process.
323    #[serde(default)]
324    pub flux_resources: Vec<FluxResourceRef>,
325
326    /// Boundary verification state.
327    #[serde(default)]
328    pub boundary: BoundaryStatus,
329
330    /// Compliance summary at the latest attestation.
331    #[serde(default)]
332    pub compliance: ComplianceStatus,
333
334    /// Pending signals (delivered, not yet handled).
335    #[serde(default)]
336    pub signal_queue: Vec<ProcessSignal>,
337
338    /// Standard K8s Conditions.
339    #[serde(default)]
340    pub conditions: Vec<ProcessCondition>,
341
342    /// Human-readable last status message.
343    #[serde(default, skip_serializing_if = "Option::is_none")]
344    pub message: Option<String>,
345
346    /// Exit code (only set on Failed / Reaped).
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub exit_code: Option<i32>,
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::classification::{ConvergencePointType, SubstrateType};
355    use crate::intent::NixIntent;
356
357    #[test]
358    fn minimal_spec_serializes() {
359        let spec = ProcessSpec {
360            identity: IdentitySpec::default(),
361            classification: Classification {
362                point_type: ConvergencePointType::Gate,
363                substrate: SubstrateType::Observability,
364                horizon: Default::default(),
365                calm: Default::default(),
366                data_classification: Default::default(),
367            },
368            intent: Intent {
369                nix: Some(NixIntent {
370                    flake_ref: "github:pleme-io/k8s".into(),
371                    attribute: "obs".into(),
372                    system: None,
373                    attic_cache: None,
374                    extra_args: vec![],
375                    delegate_to_nix_build: false,
376                }),
377                ..Intent::default()
378            },
379            boundary: Default::default(),
380            compliance: Default::default(),
381            depends_on: vec![],
382            signals: Default::default(),
383            lifetime: Default::default(),
384            routing: None,
385            encapsulates: None,
386            suspended: false,
387        };
388        let yaml = serde_yaml::to_string(&spec).unwrap();
389        assert!(yaml.contains("pointType: Gate"));
390        assert!(yaml.contains("substrate: Observability"));
391        assert!(yaml.contains("flakeRef: github:pleme-io/k8s"));
392    }
393
394    // ─── Process::coordinates_or_defaults substrate pins ────────────────
395    //
396    // Pins the (namespace, name) coordinate-primitive family on the
397    // (metadata slot × fallback shape) axis. Fail-before-pass-after
398    // granularity: a regression that flipped either fallback string,
399    // swapped the return-tuple axis order, or dropped the
400    // `Option::as_deref` unwrap surfaces here rather than as silent
401    // drift at every downstream annotation writer / claim-arbiter row
402    // builder / render owner-metadata seed.
403
404    fn empty_spec() -> ProcessSpec {
405        ProcessSpec {
406            identity: IdentitySpec::default(),
407            classification: Classification {
408                point_type: ConvergencePointType::Gate,
409                substrate: SubstrateType::Compute,
410                horizon: Default::default(),
411                calm: Default::default(),
412                data_classification: Default::default(),
413            },
414            intent: Intent::default(),
415            boundary: Default::default(),
416            compliance: Default::default(),
417            depends_on: vec![],
418            signals: Default::default(),
419            lifetime: Default::default(),
420            routing: None,
421            encapsulates: None,
422            suspended: false,
423        }
424    }
425
426    #[test]
427    fn default_namespace_constant_is_k8s_canonical_default() {
428        // Pins the load-bearing convention that this primitive's
429        // namespace fallback matches K8s's own implicit-namespace
430        // spelling. A regression that renamed this to "kube-system"
431        // or any other K8s-reserved name would silently misroute
432        // every downstream namespaced-Api call on a Process without
433        // a metadata.namespace.
434        assert_eq!(Process::DEFAULT_NAMESPACE, "default");
435    }
436
437    #[test]
438    fn unnamed_placeholder_constant_matches_prior_annotation_writer_fallback() {
439        // Pins the load-bearing convention that this primitive's name
440        // fallback matches the exact spelling every annotation writer
441        // (tatara-reconciler::ssapply::inject_annotations,
442        // tatara-reconciler::render::render, and
443        // tatara-reconciler::table_controller's claim-row builder)
444        // was hand-authoring pre-lift ("unnamed", NOT "<unnamed>" or
445        // ""). A regression that renamed this would break the
446        // annotation-writer / claim-arbiter grep contract silently.
447        assert_eq!(Process::UNNAMED_PLACEHOLDER, "unnamed");
448    }
449
450    #[test]
451    fn namespace_or_default_falls_back_when_metadata_namespace_is_none() {
452        let mut p = Process::new("some-proc", empty_spec());
453        p.metadata.namespace = None;
454        assert_eq!(p.namespace_or_default(), Process::DEFAULT_NAMESPACE);
455    }
456
457    #[test]
458    fn namespace_or_default_returns_metadata_slice_when_some() {
459        let mut p = Process::new("some-proc", empty_spec());
460        p.metadata.namespace = Some("prod-app".into());
461        assert_eq!(p.namespace_or_default(), "prod-app");
462    }
463
464    #[test]
465    fn name_or_placeholder_falls_back_when_metadata_name_is_none() {
466        let mut p = Process::new("real-name", empty_spec());
467        p.metadata.name = None;
468        assert_eq!(p.name_or_placeholder(), Process::UNNAMED_PLACEHOLDER);
469    }
470
471    #[test]
472    fn name_or_placeholder_returns_metadata_slice_when_some() {
473        let p = Process::new("api-gateway", empty_spec());
474        assert_eq!(p.name_or_placeholder(), "api-gateway");
475    }
476
477    #[test]
478    fn coordinates_or_defaults_composes_both_halves() {
479        // Both slots present — returns metadata slices in
480        // (namespace, name) axis order.
481        let mut p = Process::new("api", empty_spec());
482        p.metadata.namespace = Some("staging".into());
483        assert_eq!(p.coordinates_or_defaults(), ("staging", "api"));
484    }
485
486    #[test]
487    fn coordinates_or_defaults_falls_back_on_both_slots() {
488        // Both slots None — returns (DEFAULT_NAMESPACE,
489        // UNNAMED_PLACEHOLDER) in axis order.
490        let mut p = Process::new("scratch", empty_spec());
491        p.metadata.name = None;
492        p.metadata.namespace = None;
493        assert_eq!(
494            p.coordinates_or_defaults(),
495            (Process::DEFAULT_NAMESPACE, Process::UNNAMED_PLACEHOLDER)
496        );
497    }
498
499    #[test]
500    fn coordinates_or_defaults_mixes_slotted_and_fallback_halves() {
501        // Namespace set, name missing — the (namespace, name) tuple
502        // pins each half independently. A regression that returned
503        // BOTH fallbacks when EITHER metadata slot was None would
504        // surface here rather than at every downstream reader.
505        let mut p = Process::new("kept-name", empty_spec());
506        p.metadata.namespace = Some("prod".into());
507        assert_eq!(p.coordinates_or_defaults(), ("prod", "kept-name"));
508
509        // Name set, namespace missing — the peer corner.
510        let mut q = Process::new("api", empty_spec());
511        q.metadata.namespace = None;
512        assert_eq!(
513            q.coordinates_or_defaults(),
514            (Process::DEFAULT_NAMESPACE, "api")
515        );
516    }
517
518    // ─── Process::owned_coordinates_or_err substrate pins ──────────────
519    //
520    // Pins the owned + name-required peer of the coordinate-primitive
521    // family on the (return-form × name gate) axis pair. Fail-before-
522    // pass-after granularity: a regression that flipped the namespace
523    // fallback string, dropped the `Option::clone` unwrap, changed the
524    // return-tuple axis order, or altered the "Process has no
525    // metadata.name" error wording surfaces here rather than as silent
526    // drift at every pre-lift caller (10 sites in
527    // `tatara-reconciler::phase_machine` + 2 sites in
528    // `tatara-reconciler::signals` pre-lift).
529
530    #[test]
531    fn owned_coordinates_or_err_returns_owned_strings_when_both_slots_present() {
532        // Happy path — both slots populated, method returns owned
533        // Strings in (namespace, name) axis order.
534        let mut p = Process::new("api-gateway", empty_spec());
535        p.metadata.namespace = Some("prod-app".into());
536        let (ns, name) = p.owned_coordinates_or_err().unwrap();
537        assert_eq!(ns, "prod-app");
538        assert_eq!(name, "api-gateway");
539        // Ownership pin: type inference above binds ns/name as
540        // owned Strings — a regression that returned &str would
541        // fail to compile at the following .push() call. This
542        // holds the "owned" half of the primitive's contract.
543        let mut owned_ns = ns;
544        owned_ns.push_str("-mutated");
545        assert_eq!(owned_ns, "prod-app-mutated");
546    }
547
548    #[test]
549    fn owned_coordinates_or_err_falls_back_on_namespace_but_returns_owned_name() {
550        // Namespace absent → DEFAULT_NAMESPACE. Name present → owned.
551        let p = Process::new("api", empty_spec());
552        // Process::new leaves metadata.namespace = None by default.
553        let (ns, name) = p.owned_coordinates_or_err().unwrap();
554        assert_eq!(ns, Process::DEFAULT_NAMESPACE);
555        assert_eq!(name, "api");
556    }
557
558    #[test]
559    fn owned_coordinates_or_err_errors_when_metadata_name_absent_regardless_of_namespace() {
560        // Name absent → Err, REGARDLESS of whether the namespace is
561        // populated. The name gate is strictly on `metadata.name` and
562        // does NOT fall back to `Self::UNNAMED_PLACEHOLDER` (that
563        // fallback is on the peer `coordinates_or_defaults`, which
564        // exists precisely for consumers that can tolerate a
565        // display placeholder).
566        for ns_slot in [None, Some("prod".to_string())] {
567            let mut p = Process::new("scratch", empty_spec());
568            p.metadata.name = None;
569            p.metadata.namespace = ns_slot.clone();
570            let err = p.owned_coordinates_or_err().unwrap_err();
571            assert!(
572                err.to_string().contains("metadata.name"),
573                "err on missing name (ns={ns_slot:?}) should mention metadata.name; got {err}"
574            );
575        }
576    }
577
578    #[test]
579    fn owned_coordinates_or_err_error_message_matches_pre_lift_reconciler_wording() {
580        // Load-bearing wording pin — every pre-lift `tatara-reconciler`
581        // helper (`phase_machine::namespace_and_name`,
582        // `signals::ingest`, `signals::consume_effect`) errored with
583        // EXACTLY this wording. Post-lift the substrate owner produces
584        // the same wording so log-line / test greps that anchored on
585        // it keep matching, and no operator-visible message drift
586        // lands as a side effect of the substrate move.
587        let mut p = Process::new("scratch", empty_spec());
588        p.metadata.name = None;
589        let err = p.owned_coordinates_or_err().unwrap_err();
590        assert_eq!(err.to_string(), "Process has no metadata.name");
591    }
592
593    #[test]
594    fn owned_coordinates_or_err_namespace_fallback_matches_default_namespace_const() {
595        // Byte-identity pin between the owned form's namespace
596        // fallback and the workspace-wide `DEFAULT_NAMESPACE` const.
597        // A regression that spelled this fallback as any other
598        // string ("kube-system", "", "default-ns") would silently
599        // misroute every downstream namespaced-Api call on a
600        // Process without a metadata.namespace — surfaces here
601        // rather than at every kube-rs API caller.
602        let mut p = Process::new("api", empty_spec());
603        p.metadata.namespace = None;
604        let (ns, _) = p.owned_coordinates_or_err().unwrap();
605        assert_eq!(ns, Process::DEFAULT_NAMESPACE);
606    }
607
608    #[test]
609    fn owned_coordinates_or_err_matches_pre_lift_reconciler_helper_shape() {
610        // Byte-identical parity pin between the owned + name-required
611        // primitive here and the pre-lift `tatara-reconciler` helper
612        // shape — the exact 2-slot unwrap chain each pre-lift caller
613        // spelled by hand:
614        //
615        //   let ns = p.metadata.namespace.clone().unwrap_or_else(|| "default".into());
616        //   let name = p.metadata.name.clone().ok_or_else(|| anyhow!(...))?;
617        //   Ok((ns, name))
618        //
619        // Sweeps every corner every callsite plausibly encounters
620        // (both slots present, namespace absent, name absent, both
621        // absent). A regression that inserted a normalization step
622        // at the primitive that the pre-lift chain does NOT apply —
623        // or vice versa — surfaces here rather than as silent drift
624        // between the 12 pre-lift consumer callsites and the ONE
625        // substrate owner they now route through.
626        fn pre_lift(p: &Process) -> anyhow::Result<(String, String)> {
627            let ns = p
628                .metadata
629                .namespace
630                .clone()
631                .unwrap_or_else(|| "default".into());
632            let name = p
633                .metadata
634                .name
635                .clone()
636                .ok_or_else(|| anyhow::anyhow!("Process has no metadata.name"))?;
637            Ok((ns, name))
638        }
639        // Both present.
640        let mut p = Process::new("api", empty_spec());
641        p.metadata.namespace = Some("prod".into());
642        assert_eq!(p.owned_coordinates_or_err().unwrap(), pre_lift(&p).unwrap());
643        // Namespace absent.
644        let p = Process::new("api", empty_spec());
645        assert_eq!(p.owned_coordinates_or_err().unwrap(), pre_lift(&p).unwrap());
646        // Name absent → both variants error with the same wording.
647        let mut p = Process::new("api", empty_spec());
648        p.metadata.name = None;
649        p.metadata.namespace = Some("prod".into());
650        assert_eq!(
651            p.owned_coordinates_or_err().unwrap_err().to_string(),
652            pre_lift(&p).unwrap_err().to_string(),
653        );
654        // Both absent → still errors on the name gate.
655        let mut p = Process::new("api", empty_spec());
656        p.metadata.name = None;
657        p.metadata.namespace = None;
658        assert_eq!(
659            p.owned_coordinates_or_err().unwrap_err().to_string(),
660            pre_lift(&p).unwrap_err().to_string(),
661        );
662    }
663
664    #[test]
665    fn owned_coordinates_or_err_axis_order_matches_coordinates_or_defaults() {
666        // Cross-primitive coherence pin between the owned + name-
667        // required form and the borrow + name-defaulted peer:
668        // (namespace, name) axis order is IDENTICAL across both
669        // return-forms. A regression that swapped the tuple slots on
670        // only ONE of the two primitives would silently misroute
671        // every consumer that picked between the two forms based on
672        // its callsite's ownership needs. The pin re-reads both
673        // primitives at test time so the equality holds iff both
674        // live paths are the current implementation.
675        let mut p = Process::new("app", empty_spec());
676        p.metadata.namespace = Some("infra".into());
677        let (borrow_ns, borrow_name) = p.coordinates_or_defaults();
678        let (owned_ns, owned_name) = p.owned_coordinates_or_err().unwrap();
679        assert_eq!(owned_ns, borrow_ns);
680        assert_eq!(owned_name, borrow_name);
681        // Explicit slot labels — pins the (namespace, name) axis
682        // order as opposed to (name, namespace).
683        assert_eq!(owned_ns, "infra"); // NOT "app"
684        assert_eq!(owned_name, "app"); // NOT "infra"
685    }
686
687    // ─── Process::coordinates_or_none substrate pins ──────────────────
688    //
689    // Pins the borrow + name-required peer of the coordinate-primitive
690    // family on the (return-form × name-gate) axis pair. Closes the
691    // corner previously left open (borrow + name-required) so the
692    // three consumer shapes (child-Process delete-fan-out at
693    // `phase_machine::handle_exiting`, claim-arbiter probe at
694    // `phase_machine::process_holds_any_claim`, any future non-fatal
695    // skip site) route through ONE primitive rather than three hand-
696    // authored empty-string / `unwrap_or_default()` sentinel chains.
697    // Fail-before-pass-after granularity: a regression that flipped
698    // the namespace fallback, swapped the return-tuple axis order,
699    // returned an owned form, or promoted a missing name to an error
700    // rather than `None` surfaces here rather than as silent drift at
701    // every borrow + name-required consumer.
702
703    #[test]
704    fn coordinates_or_none_returns_slices_when_both_slots_present() {
705        // Happy path — both slots populated, method returns borrowed
706        // (&str, &str) in (namespace, name) axis order wrapped in
707        // `Some`.
708        let mut p = Process::new("api-gateway", empty_spec());
709        p.metadata.namespace = Some("prod-app".into());
710        let (ns, name) = p.coordinates_or_none().expect("Some when name set");
711        assert_eq!(ns, "prod-app");
712        assert_eq!(name, "api-gateway");
713    }
714
715    #[test]
716    fn coordinates_or_none_falls_back_on_namespace_but_returns_name_slice() {
717        // Namespace absent → DEFAULT_NAMESPACE (shared with the peer
718        // `coordinates_or_defaults` + `namespace_or_default`). Name
719        // present → the metadata slice, wrapped in `Some`.
720        let mut p = Process::new("api", empty_spec());
721        p.metadata.namespace = None;
722        let (ns, name) = p.coordinates_or_none().expect("Some when name set");
723        assert_eq!(ns, Process::DEFAULT_NAMESPACE);
724        assert_eq!(name, "api");
725    }
726
727    #[test]
728    fn coordinates_or_none_returns_none_when_metadata_name_absent_regardless_of_namespace() {
729        // Name absent → `None`, REGARDLESS of whether the namespace
730        // slot is populated. The name gate is strictly on
731        // `metadata.name` and does NOT fall back to
732        // `Self::UNNAMED_PLACEHOLDER` (that fallback is on the peer
733        // `coordinates_or_defaults`, which exists precisely for
734        // consumers that tolerate a display placeholder). Peer to
735        // `owned_coordinates_or_err_errors_when_metadata_name_absent_regardless_of_namespace`
736        // on the sibling primitive; a regression that widened THIS
737        // form to substitute the placeholder while leaving the owned
738        // form strict would silently drift the two borrow-form
739        // primitives out of the coherence the family carries.
740        for ns_slot in [None, Some("prod".to_string())] {
741            let mut p = Process::new("scratch", empty_spec());
742            p.metadata.name = None;
743            p.metadata.namespace = ns_slot.clone();
744            assert!(
745                p.coordinates_or_none().is_none(),
746                "coordinates_or_none must be None on missing name (ns={ns_slot:?})",
747            );
748        }
749    }
750
751    #[test]
752    fn coordinates_or_none_namespace_fallback_matches_default_namespace_const() {
753        // Byte-identity pin between the borrow + name-required form's
754        // namespace fallback and the workspace-wide `DEFAULT_NAMESPACE`
755        // const. Sibling to
756        // `owned_coordinates_or_err_namespace_fallback_matches_default_namespace_const`
757        // on the peer primitive — the two forms MUST substitute the
758        // same fallback string, else a consumer that switches between
759        // them based on its ownership need silently observes a
760        // different namespace-fallback shape as a side effect.
761        let mut p = Process::new("api", empty_spec());
762        p.metadata.namespace = None;
763        let (ns, _) = p.coordinates_or_none().unwrap();
764        assert_eq!(ns, Process::DEFAULT_NAMESPACE);
765    }
766
767    #[test]
768    fn coordinates_or_none_axis_order_matches_coordinates_or_defaults_when_name_present() {
769        // Cross-primitive coherence pin between the two borrow-form
770        // primitives: when the name is present, the (namespace, name)
771        // return-tuple axis order is IDENTICAL across the two forms,
772        // and the returned slices are the SAME `&str` view onto the
773        // same metadata slots. A regression that swapped the tuple
774        // slots on ONE form would silently misroute every consumer
775        // that picked between the two forms based on its name-gate
776        // need. The pin re-reads both primitives at test time so the
777        // equality holds iff both live paths are the current
778        // implementation.
779        let mut p = Process::new("app", empty_spec());
780        p.metadata.namespace = Some("infra".into());
781        let (defaulted_ns, defaulted_name) = p.coordinates_or_defaults();
782        let (required_ns, required_name) = p.coordinates_or_none().unwrap();
783        assert_eq!(defaulted_ns, required_ns);
784        assert_eq!(defaulted_name, required_name);
785        // Explicit slot labels — pins the (namespace, name) axis order
786        // as opposed to (name, namespace).
787        assert_eq!(required_ns, "infra"); // NOT "app"
788        assert_eq!(required_name, "app"); // NOT "infra"
789    }
790
791    #[test]
792    fn coordinates_or_none_axis_pair_diverges_from_coordinates_or_defaults_on_missing_name() {
793        // Divergence pin between the two borrow-form primitives when
794        // the name gate fires: `coordinates_or_defaults` substitutes
795        // the display placeholder AND still returns a tuple;
796        // `coordinates_or_none` returns `None`. A regression that
797        // collapsed the two behaviors (either by dropping the gate
798        // from the required form or by adding a `None` corner to the
799        // defaulted form) would blur the axis pair's whole reason to
800        // exist as two peer primitives.
801        let mut p = Process::new("scratch", empty_spec());
802        p.metadata.name = None;
803        p.metadata.namespace = Some("prod".into());
804        // Defaulted form: substitutes placeholder, no gate.
805        assert_eq!(
806            p.coordinates_or_defaults(),
807            ("prod", Process::UNNAMED_PLACEHOLDER)
808        );
809        // Required form: gate fires, `None`.
810        assert!(p.coordinates_or_none().is_none());
811    }
812
813    #[test]
814    fn coordinates_or_none_matches_pre_lift_reconciler_helper_shape() {
815        // Byte-identical parity pin between the borrow + name-required
816        // primitive here and the pre-lift `tatara-reconciler` helper
817        // shapes — the exact 2-slot unwrap + gate chains each pre-lift
818        // caller spelled by hand (`phase_machine::process_holds_any_claim`
819        // spelled it as `unwrap_or("")` + `is_empty` early-return;
820        // `phase_machine::handle_exiting`'s child-fan-out spelled it
821        // as `unwrap_or_default()` + implicit no-op delete on the
822        // empty API-path). Sweeps every corner every callsite plausibly
823        // encounters (both slots present, namespace absent, name
824        // absent + ns present, both absent). A regression that
825        // inserted a normalization step at the primitive the pre-lift
826        // chain does NOT apply — or vice versa — surfaces here rather
827        // than as silent drift between the pre-lift consumer sites
828        // and the ONE substrate owner they now route through.
829        fn pre_lift_holds_any_claim(p: &Process) -> Option<(&str, &str)> {
830            let ns = p.metadata.namespace.as_deref().unwrap_or("default");
831            let name = p.metadata.name.as_deref().unwrap_or("");
832            if name.is_empty() {
833                return None;
834            }
835            Some((ns, name))
836        }
837        // Both present.
838        let mut p = Process::new("api", empty_spec());
839        p.metadata.namespace = Some("prod".into());
840        assert_eq!(p.coordinates_or_none(), pre_lift_holds_any_claim(&p));
841        // Namespace absent.
842        let p = Process::new("api", empty_spec());
843        assert_eq!(p.coordinates_or_none(), pre_lift_holds_any_claim(&p));
844        // Name absent → both variants return `None` regardless of ns.
845        let mut p = Process::new("api", empty_spec());
846        p.metadata.name = None;
847        p.metadata.namespace = Some("prod".into());
848        assert_eq!(p.coordinates_or_none(), pre_lift_holds_any_claim(&p));
849        // Both absent → still `None` on the name gate.
850        let mut p = Process::new("api", empty_spec());
851        p.metadata.name = None;
852        p.metadata.namespace = None;
853        assert_eq!(p.coordinates_or_none(), pre_lift_holds_any_claim(&p));
854    }
855
856    #[test]
857    fn coordinates_or_none_axis_order_matches_owned_coordinates_or_err_on_happy_path() {
858        // Cross-primitive coherence pin at the sibling corner: when
859        // BOTH slots are present, the borrow + name-required form
860        // (this method) and the owned + name-required peer
861        // (`owned_coordinates_or_err`) return the SAME `(ns, name)`
862        // pair — the axis order is IDENTICAL and neither primitive
863        // silently applies a normalization the other omits. A
864        // regression that skewed one form's normalization would
865        // surface here rather than as silent drift between the two
866        // name-required corners of the primitive family.
867        let mut p = Process::new("app", empty_spec());
868        p.metadata.namespace = Some("infra".into());
869        let (borrow_ns, borrow_name) = p.coordinates_or_none().unwrap();
870        let (owned_ns, owned_name) = p.owned_coordinates_or_err().unwrap();
871        assert_eq!(borrow_ns, owned_ns.as_str());
872        assert_eq!(borrow_name, owned_name.as_str());
873    }
874
875    #[test]
876    fn coordinates_or_defaults_axis_order_matches_qualified_process_ref() {
877        // Pins the load-bearing convention that the return-tuple
878        // axis order is (namespace, name) — the exact positional
879        // argument order the substrate's paired-composer primitive
880        // `tatara_reconciler::ssapply::qualified_process_ref(ns,
881        // name)` consumes. A regression that swapped the tuple
882        // slots would silently misroute every annotation writer /
883        // claim-arbiter row / owner-metadata seed built by feeding
884        // this pair into the composer — every downstream `<ns>/
885        // <name>` grep would suddenly see `<name>/<ns>`. The test
886        // verifies the tuple's first slot is what a hand-authored
887        // `.metadata.namespace.as_deref()...` produced pre-lift, and
888        // the second slot is what `.metadata.name.as_deref()...`
889        // produced.
890        let mut p = Process::new("app", empty_spec());
891        p.metadata.namespace = Some("infra".into());
892        let (ns, name) = p.coordinates_or_defaults();
893        assert_eq!(ns, "infra"); // NOT "app"
894        assert_eq!(name, "app"); // NOT "infra"
895    }
896}