Skip to main content

tatara_process/
status.rs

1//! `ProcessStatus` sub-structures — conditions, checked boundaries, Flux refs.
2
3use chrono::{DateTime, Utc};
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7
8use crate::boundary::Condition;
9use crate::crd::Process;
10
11/// Standard K8s Condition (shape of `metav1.Condition`).
12#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
13#[serde(rename_all = "camelCase")]
14pub struct ProcessCondition {
15    #[serde(rename = "type")]
16    pub type_: String,
17    pub status: String,
18    pub last_transition_time: DateTime<Utc>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub reason: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub message: Option<String>,
23}
24
25impl ProcessCondition {
26    pub fn ready(reason: impl Into<String>, message: Option<String>) -> Self {
27        Self {
28            type_: "Ready".into(),
29            status: "True".into(),
30            last_transition_time: Utc::now(),
31            reason: Some(reason.into()),
32            message,
33        }
34    }
35
36    pub fn not_ready(reason: impl Into<String>, message: impl Into<String>) -> Self {
37        Self {
38            type_: "Ready".into(),
39            status: "False".into(),
40            last_transition_time: Utc::now(),
41            reason: Some(reason.into()),
42            message: Some(message.into()),
43        }
44    }
45
46    pub fn attested(root: &str) -> Self {
47        Self {
48            type_: "Attested".into(),
49            status: "True".into(),
50            last_transition_time: Utc::now(),
51            reason: Some("AttestationWritten".into()),
52            message: Some(format!("composed_root={root}")),
53        }
54    }
55}
56
57/// Reference to a FluxCD resource emitted as part of this Process.
58#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
59#[serde(rename_all = "camelCase")]
60pub struct FluxResourceRef {
61    pub api_version: String,
62    pub kind: String,
63    pub name: String,
64    pub namespace: String,
65    #[serde(default)]
66    pub ready: bool,
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub message: Option<String>,
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub last_check: Option<DateTime<Utc>>,
71}
72
73impl FluxResourceRef {
74    /// Pure typed projection of the four fetch coordinates
75    /// `(namespace, api_version, kind, name)` every consumer that
76    /// dispatches this persisted reference through kube-rs's dynamic-
77    /// object surface splats by hand pre-lift. The 4-tuple binds the
78    /// slot order at ONE typed accessor so a copy-paste at any downstream
79    /// consumer cannot swap two adjacent `&str` slots in the fetch call.
80    ///
81    /// Peer projection to
82    /// [`crate::k8s_wire_identity::K8sWireIdentity`] on the static-
83    /// identity axis: [`K8sWireIdentity`] carries a
84    /// `(&'static str, &'static str)` closed-set variant's pair for
85    /// emit-time (RENDER phase) composition; this method carries the
86    /// full `(ns, apiVersion, kind, name)` 4-slot borrow for fetch-time
87    /// (VERIFY / ATTEST-heartbeat) composition where the ref's payload
88    /// comes back off the persisted `ProcessStatus.flux_resources`
89    /// slice with owned `String`s rather than static literals. The two
90    /// primitives partition the fetch axis by whether the caller starts
91    /// from a closed-set variant (emit-time) or a persisted status
92    /// slice (fetch-time).
93    ///
94    /// Pre-lift the 5-slot `ssapply::fetch(client, &r.namespace,
95    /// &r.api_version, &r.kind, &r.name)` splat was hand-authored at
96    /// TWO sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold
97    /// in `tatara-reconciler::phase_machine`:
98    /// * `handle_running` — the VERIFY-phase per-ref readiness probe
99    ///   that populates the updated `FluxResourceRef` slice with
100    ///   `ready` + `message` + `last_check`.
101    /// * `handle_attested` — the ATTEST-heartbeat drift detector that
102    ///   short-circuits on the first non-Ready ref.
103    ///
104    /// Both sites splatted the SAME four `&r.X` field borrows in the
105    /// SAME order into raw `ssapply::fetch`. A copy-paste that swapped
106    /// two adjacent `&str` slots (`&r.api_version` and `&r.kind` are
107    /// both strings that look interchangeable to a mechanical
108    /// substitution) would silently 404 at wire time and diagnose as a
109    /// broken CRD rather than as slot skew at the callsite. Post-lift
110    /// each site names the ref ONCE and unpacks it through this ONE
111    /// projection; the slot order binds structurally at the tuple
112    /// return so a caller cannot desync one axis.
113    ///
114    /// A future addition (a case-fold normalization on the group, a
115    /// virtual-cluster prefix rewrite for multi-tenancy, a
116    /// `generateName` fallback on the name slot, a cluster-cache
117    /// short-circuit inserted between the projection and the fetch
118    /// call) lands at this ONE method and every downstream fetch
119    /// consumer inherits the upgrade mechanically — no per-site edit
120    /// at `handle_running` / `handle_attested` / any future kenshi-
121    /// runner / mirror-audit / drift-probe consumer that grows a third
122    /// consumer.
123    ///
124    /// Return-order pin lives at
125    /// [`tests::flux_resource_ref_fetch_coords_binds_slots_by_position`]
126    /// so a regression that swapped `namespace` and `api_version`
127    /// (both `String`, same type) inside the tuple constructor fails-
128    /// loudly here rather than as a silent wire-time 404 at every
129    /// downstream fetch consumer.
130    ///
131    /// Theory grounding: THEORY.md §II.1 invariant 5 (composition
132    /// preserves proofs — the 4-tuple slot order binds at ONE typed
133    /// projection so a regression across the two fields of the same
134    /// `String` type fails at the projection's positional pin rather
135    /// than at every downstream fetch consumer). THEORY.md §VI.1
136    /// (generation over composition — the 5-slot splat recurred at
137    /// two hand-authored sites past the ≥ 2 duplication trigger, and
138    /// is lifted to ONE typed borrow-projection here).
139    pub fn fetch_coords(&self) -> (&str, &str, &str, &str) {
140        (&self.namespace, &self.api_version, &self.kind, &self.name)
141    }
142}
143
144/// Identifying coordinates of a rendered K8s resource — the
145/// `(apiVersion, kind, metadata.name, metadata.namespace)` 4-tuple
146/// every consumer that walks a rendered `serde_json::Value` resource
147/// unwraps by hand pre-lift.
148///
149/// The three K8s API-path segments (`apiVersion`, `kind`,
150/// `metadata.name`) are REQUIRED — a rendered resource missing any
151/// of them cannot be applied via kube-rs's dynamic API surface, so
152/// the extraction fails fast at the boundary rather than as a
153/// downstream `Api::patch` panic. `metadata.namespace` is
154/// intentionally kept as `Option<String>` because different consumers
155/// resolve the fallback differently: `apply_owned` uses the
156/// caller-supplied `namespace: &str` argument (the reconciler already
157/// resolved the target namespace upstream), while `flux_ref_from_json`
158/// records the K8s canonical `"default"` fallback into the persisted
159/// `FluxResourceRef.namespace` slot. The peer method
160/// [`Self::namespace_or_default`] applies the K8s canonical fallback
161/// (`Process::DEFAULT_NAMESPACE = "default"`) for consumers wanting
162/// the same shape [`FluxResourceRef.namespace`] carries.
163///
164/// Pre-lift the 3+1 slot extraction was hand-authored at TWO sites
165/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold across
166/// `tatara-reconciler`:
167/// * `tatara-reconciler::phase_machine::flux_ref_from_json` — the
168///   post-SSA `FluxResourceRef` builder that persists into
169///   `ProcessStatus.flux_resources`; namespace half fallback-
170///   defaulted to `"default"`.
171/// * `tatara-reconciler::ssapply::apply_owned` — the SSA entry
172///   point that extracts (apiVersion, kind, name) for the
173///   [`kube::Api::patch`] call; namespace half discarded (the
174///   `namespace: &str` argument comes from the caller upstream).
175///
176/// Both callsites restated the same three
177/// `.get(K).and_then(|v| v.as_str()).ok_or_else(|| anyhow!(...))?
178/// .to_string()` incantations with subtly different error wording
179/// (`"resource missing X"` vs `"rendered resource missing X"`); post-
180/// lift both route through this ONE substrate owner with the
181/// canonical `"rendered resource missing X"` wording. A future
182/// addition (case-fold on the group, a rename of the namespace
183/// fallback, a stricter kind gate, a Unicode-safe collation step,
184/// support for `metadata.generateName` as a name fallback) lands at
185/// the primitive's body on the substrate, not at 2 independent
186/// hand-writes across 2 reconciler files.
187///
188/// Namespace fallback const is shared with
189/// [`Process::DEFAULT_NAMESPACE`] — a rename of the K8s canonical
190/// default namespace lands at that ONE workspace-wide const, not at
191/// per-primitive local literals that would drift silently.
192#[derive(Clone, Debug, PartialEq, Eq)]
193pub struct RenderedResourceCoords {
194    /// `apiVersion` — the group+version pair kube-rs uses to resolve
195    /// the `ApiResource` for the SSA call.
196    pub api_version: String,
197    /// `kind` — the resource kind (Kustomization, HelmRelease, …).
198    pub kind: String,
199    /// `metadata.name` — the API-path leaf segment.
200    pub name: String,
201    /// `metadata.namespace` — raw from the resource, `None` when the
202    /// slot is absent (a cluster-scoped resource, or a namespaced
203    /// resource whose namespace was left for the API server to
204    /// substitute). Consumers apply their own fallback:
205    /// [`Self::namespace_or_default`] applies the K8s canonical
206    /// `"default"` (matching what [`FluxResourceRef.namespace`]
207    /// records); other consumers substitute a caller-supplied string
208    /// (see `tatara-reconciler::ssapply::apply_owned`).
209    pub namespace: Option<String>,
210}
211
212impl RenderedResourceCoords {
213    /// Extract the 4-tuple from a rendered K8s resource JSON `Value`.
214    ///
215    /// Fails with a canonical `"rendered resource missing X"` message
216    /// when any of the three required slots (`apiVersion`, `kind`,
217    /// `metadata.name`) is absent or non-string; `metadata.namespace`
218    /// is optional and captured as `None` when absent.
219    ///
220    /// The error wording is pinned by
221    /// [`tests::rendered_resource_coords_error_wording_is_canonical`]
222    /// so a regression that reshaped the message surfaces at the test
223    /// surface rather than as silent drift between the two pre-lift
224    /// call sites (which used subtly different wording — `"resource
225    /// missing X"` in `apply_owned` vs `"rendered resource missing
226    /// X"` in `flux_ref_from_json`).
227    pub fn from_json(res: &Value) -> anyhow::Result<Self> {
228        let api_version = res
229            .get("apiVersion")
230            .and_then(|v| v.as_str())
231            .ok_or_else(|| anyhow::anyhow!("rendered resource missing apiVersion"))?
232            .to_string();
233        let kind = res
234            .get("kind")
235            .and_then(|v| v.as_str())
236            .ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))?
237            .to_string();
238        let metadata = res.get("metadata");
239        let name = metadata
240            .and_then(|m| m.get("name"))
241            .and_then(|v| v.as_str())
242            .ok_or_else(|| anyhow::anyhow!("rendered resource missing metadata.name"))?
243            .to_string();
244        let namespace = metadata
245            .and_then(|m| m.get("namespace"))
246            .and_then(|v| v.as_str())
247            .map(str::to_string);
248        Ok(Self {
249            api_version,
250            kind,
251            name,
252            namespace,
253        })
254    }
255
256    /// `metadata.namespace` slice with the K8s canonical `"default"`
257    /// fallback applied — matching what [`Process::DEFAULT_NAMESPACE`]
258    /// spells for the `Process`-borne coordinate primitive family
259    /// and what [`FluxResourceRef.namespace`] records into
260    /// `ProcessStatus.flux_resources`.
261    pub fn namespace_or_default(&self) -> &str {
262        self.namespace
263            .as_deref()
264            .unwrap_or(Process::DEFAULT_NAMESPACE)
265    }
266}
267
268/// A boundary condition paired with its current satisfaction state.
269#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
270#[serde(rename_all = "camelCase")]
271pub struct CheckedCondition {
272    #[serde(flatten)]
273    pub condition: Condition,
274    pub satisfied: bool,
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub last_check: Option<DateTime<Utc>>,
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub message: Option<String>,
279}
280
281/// Summary of boundary verification.
282#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
283#[serde(rename_all = "camelCase")]
284pub struct BoundaryStatus {
285    #[serde(default)]
286    pub preconditions: Vec<CheckedCondition>,
287    #[serde(default)]
288    pub postconditions: Vec<CheckedCondition>,
289    /// Absolute deadline for VERIFY (derived from `spec.boundary.timeout`).
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub deadline: Option<DateTime<Utc>>,
292}
293
294/// Summary of compliance checks at the latest attestation.
295#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
296#[serde(rename_all = "camelCase")]
297pub struct ComplianceStatus {
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub baseline: Option<String>,
300    pub satisfied: u32,
301    pub violated: u32,
302    pub total: u32,
303    #[serde(default)]
304    pub violations: Vec<String>,
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use serde_json::json;
311
312    // ─── RenderedResourceCoords substrate pins ──────────────────────
313
314    #[test]
315    fn rendered_resource_coords_from_json_extracts_all_four_slots_when_present() {
316        let res = json!({
317            "apiVersion": "kustomize.toolkit.fluxcd.io/v1",
318            "kind": "Kustomization",
319            "metadata": {
320                "name": "observability-stack",
321                "namespace": "flux-system",
322            },
323        });
324        let c = RenderedResourceCoords::from_json(&res).expect("extract");
325        assert_eq!(c.api_version, "kustomize.toolkit.fluxcd.io/v1");
326        assert_eq!(c.kind, "Kustomization");
327        assert_eq!(c.name, "observability-stack");
328        assert_eq!(c.namespace.as_deref(), Some("flux-system"));
329    }
330
331    #[test]
332    fn rendered_resource_coords_from_json_captures_absent_namespace_as_none() {
333        // Cluster-scoped resource — `metadata.namespace` intentionally absent.
334        let res = json!({
335            "apiVersion": "v1",
336            "kind": "Namespace",
337            "metadata": {"name": "demo-test"},
338        });
339        let c = RenderedResourceCoords::from_json(&res).expect("extract");
340        assert_eq!(c.namespace, None);
341        assert_eq!(c.name, "demo-test");
342    }
343
344    #[test]
345    fn rendered_resource_coords_from_json_errors_on_missing_api_version() {
346        let res = json!({"kind": "K", "metadata": {"name": "n"}});
347        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
348        assert_eq!(e.to_string(), "rendered resource missing apiVersion");
349    }
350
351    #[test]
352    fn rendered_resource_coords_from_json_errors_on_missing_kind() {
353        let res = json!({"apiVersion": "v1", "metadata": {"name": "n"}});
354        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
355        assert_eq!(e.to_string(), "rendered resource missing kind");
356    }
357
358    #[test]
359    fn rendered_resource_coords_from_json_errors_on_missing_metadata_name() {
360        let res = json!({"apiVersion": "v1", "kind": "K", "metadata": {}});
361        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
362        assert_eq!(e.to_string(), "rendered resource missing metadata.name");
363    }
364
365    #[test]
366    fn rendered_resource_coords_from_json_errors_on_missing_metadata_object() {
367        // `metadata` absent entirely — same failure as `metadata.name` missing,
368        // because the API-path leaf segment cannot be resolved.
369        let res = json!({"apiVersion": "v1", "kind": "K"});
370        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
371        assert_eq!(e.to_string(), "rendered resource missing metadata.name");
372    }
373
374    #[test]
375    fn rendered_resource_coords_from_json_errors_on_non_string_slot() {
376        // A numeric `apiVersion` slot falls through the `.as_str()` gate and
377        // triggers the same missing-slot failure as absence — the API-path
378        // segment is not a string.
379        let res = json!({
380            "apiVersion": 42,
381            "kind": "K",
382            "metadata": {"name": "n"},
383        });
384        let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
385        assert_eq!(e.to_string(), "rendered resource missing apiVersion");
386    }
387
388    #[test]
389    fn rendered_resource_coords_error_wording_is_canonical() {
390        // Pins the exact spelling every downstream consumer sees.
391        // Pre-lift wording differed across the two call sites (`"resource
392        // missing X"` in `apply_owned` vs `"rendered resource missing X"` in
393        // `flux_ref_from_json`); post-lift the canonical wording is
394        // `"rendered resource missing X"` at every site.
395        let cases = [
396            (
397                "apiVersion",
398                json!({"kind": "K", "metadata": {"name": "n"}}),
399            ),
400            (
401                "kind",
402                json!({"apiVersion": "v1", "metadata": {"name": "n"}}),
403            ),
404            (
405                "metadata.name",
406                json!({"apiVersion": "v1", "kind": "K", "metadata": {}}),
407            ),
408        ];
409        for (slot, res) in cases {
410            let e = RenderedResourceCoords::from_json(&res).expect_err("must error");
411            assert_eq!(
412                e.to_string(),
413                format!("rendered resource missing {slot}"),
414                "slot {slot} error must be canonical"
415            );
416        }
417    }
418
419    #[test]
420    fn rendered_resource_coords_namespace_or_default_returns_slice_when_some() {
421        let c = RenderedResourceCoords {
422            api_version: "v1".into(),
423            kind: "K".into(),
424            name: "n".into(),
425            namespace: Some("prod".into()),
426        };
427        assert_eq!(c.namespace_or_default(), "prod");
428    }
429
430    #[test]
431    fn rendered_resource_coords_namespace_or_default_falls_back_when_none() {
432        let c = RenderedResourceCoords {
433            api_version: "v1".into(),
434            kind: "K".into(),
435            name: "n".into(),
436            namespace: None,
437        };
438        assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
439        assert_eq!(c.namespace_or_default(), "default");
440    }
441
442    // ─── FluxResourceRef::fetch_coords substrate pins ─────────────
443    //
444    // The 4-slot `(&namespace, &api_version, &kind, &name)` borrow
445    // projection lifts the pre-existing 5-slot `ssapply::fetch(client,
446    // &r.namespace, &r.api_version, &r.kind, &r.name)` splat that
447    // recurred at TWO hand-authored sites in
448    // `tatara-reconciler::phase_machine` (`handle_running`,
449    // `handle_attested`) past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
450    // trigger. These pins bind the slot order at fail-before-pass-
451    // after granularity so a regression that swapped `namespace` and
452    // `api_version` (both `String`, mechanically interchangeable to
453    // a bad refactor) surfaces HERE rather than as a silent wire-time
454    // 404 at every downstream Flux fetch consumer.
455
456    fn sample_flux_ref() -> FluxResourceRef {
457        // Slot values are deliberately distinct so a swap between any
458        // two adjacent tuple positions surfaces as an equality
459        // failure at the assertion site — a slot-inversion regression
460        // cannot masquerade as identity by accident.
461        FluxResourceRef {
462            api_version: "kustomize.toolkit.fluxcd.io/v1".to_string(),
463            kind: "Kustomization".to_string(),
464            name: "observability-stack".to_string(),
465            namespace: "flux-system".to_string(),
466            ready: true,
467            message: None,
468            last_check: None,
469        }
470    }
471
472    #[test]
473    fn flux_resource_ref_fetch_coords_binds_slots_by_position() {
474        // Positional pin: the 4-tuple return binds
475        // `(namespace, api_version, kind, name)` in THAT order,
476        // matching the raw `ssapply::fetch(client, ns, av, kind,
477        // name)` positional signature every pre-lift callsite splatted
478        // into. A regression that swapped ANY pair of adjacent slots
479        // (all four axes are `String` and mechanically
480        // indistinguishable at the type level) would surface here
481        // rather than as an operator-visible wire-form 404 at every
482        // downstream fetch consumer.
483        let r = sample_flux_ref();
484        let (ns, av, kind, name) = r.fetch_coords();
485        assert_eq!(ns, "flux-system", "position 0 must be namespace");
486        assert_eq!(
487            av, "kustomize.toolkit.fluxcd.io/v1",
488            "position 1 must be api_version"
489        );
490        assert_eq!(kind, "Kustomization", "position 2 must be kind");
491        assert_eq!(name, "observability-stack", "position 3 must be name");
492    }
493
494    #[test]
495    fn flux_resource_ref_fetch_coords_returns_borrows_of_owned_slots() {
496        // Borrow-discipline pin: the 4-tuple returns `&str` borrows
497        // of the enclosing `FluxResourceRef`'s owned `String` slots —
498        // NOT a fresh allocation or a clone. A regression that
499        // switched the projection to owned strings (via `.clone()` or
500        // `format!`) would defeat the zero-copy contract and would
501        // surface here via pointer-identity comparison.
502        let r = sample_flux_ref();
503        let (ns, av, kind, name) = r.fetch_coords();
504        assert!(std::ptr::eq(ns.as_ptr(), r.namespace.as_ptr()));
505        assert!(std::ptr::eq(av.as_ptr(), r.api_version.as_ptr()));
506        assert!(std::ptr::eq(kind.as_ptr(), r.kind.as_ptr()));
507        assert!(std::ptr::eq(name.as_ptr(), r.name.as_ptr()));
508    }
509
510    #[test]
511    fn flux_resource_ref_fetch_coords_is_a_pure_borrow_projection() {
512        // Purity pin: calling the projection twice on the same ref
513        // returns byte-identical slices (same pointer, same length).
514        // A regression that introduced state — a lazy-cached slot
515        // computed on first call, a normalization step that ran once
516        // and cached — would surface here rather than as silent drift
517        // between the VERIFY-phase and ATTEST-heartbeat consumers on
518        // the SAME ref within one reconcile pass.
519        let r = sample_flux_ref();
520        let a = r.fetch_coords();
521        let b = r.fetch_coords();
522        assert!(std::ptr::eq(a.0.as_ptr(), b.0.as_ptr()));
523        assert!(std::ptr::eq(a.1.as_ptr(), b.1.as_ptr()));
524        assert!(std::ptr::eq(a.2.as_ptr(), b.2.as_ptr()));
525        assert!(std::ptr::eq(a.3.as_ptr(), b.3.as_ptr()));
526    }
527
528    #[test]
529    fn flux_resource_ref_fetch_coords_ignores_status_slots() {
530        // Coverage pin: the projection exposes ONLY the four API-path
531        // slots the fetch call requires; the ref's status slots
532        // (`ready`, `message`, `last_check`) are deliberately absent
533        // from the tuple. The fetch signature admits four `&str`
534        // slots, and the projection carries EXACTLY those four — no
535        // silent widening that would surface as an arity mismatch at
536        // every downstream `fetch(...)` call.
537        let r = sample_flux_ref();
538        let coords = r.fetch_coords();
539        assert_eq!(
540            std::mem::size_of_val(&coords),
541            std::mem::size_of::<(&str, &str, &str, &str)>(),
542            "the 4-tuple width must match the raw fetch signature's four `&str` slots"
543        );
544    }
545
546    #[test]
547    fn rendered_resource_coords_namespace_fallback_shares_process_default_const() {
548        // Byte-identity between the namespace fallback and the workspace-
549        // wide `Process::DEFAULT_NAMESPACE` const. A regression that spelled
550        // the fallback as any other string ("kube-system", "", "default-ns")
551        // would silently drift between the coord-primitive family here and
552        // the `Process`-borne family in `crd.rs` — surfaces here rather than
553        // as operator-observed namespace routing skew between the two
554        // primitive families.
555        let c = RenderedResourceCoords {
556            api_version: "v1".into(),
557            kind: "K".into(),
558            name: "n".into(),
559            namespace: None,
560        };
561        assert_eq!(c.namespace_or_default(), Process::DEFAULT_NAMESPACE);
562    }
563}