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
244/// Process status — every field optional until the reconciler writes it.
245#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
246#[serde(rename_all = "camelCase")]
247pub struct ProcessStatus {
248 /// Hierarchical PID path — e.g., `"seph.1.7"`.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub pid: Option<String>,
251
252 /// Parent PID path (mirror of `spec.identity.parent`, resolved at fork).
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub parent: Option<String>,
255
256 /// Direct children's PID paths.
257 #[serde(default)]
258 pub children: Vec<String>,
259
260 /// Resolved identity (name + content hash).
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub identity: Option<Identity>,
263
264 /// Current phase.
265 #[serde(default)]
266 pub phase: ProcessPhase,
267
268 /// When the process entered the current phase.
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub phase_since: Option<DateTime<Utc>>,
271
272 /// Three-pillar attestation (written at end of every successful cycle).
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub attestation: Option<ProcessAttestation>,
275
276 /// FluxCD resources currently owned by this Process.
277 #[serde(default)]
278 pub flux_resources: Vec<FluxResourceRef>,
279
280 /// Boundary verification state.
281 #[serde(default)]
282 pub boundary: BoundaryStatus,
283
284 /// Compliance summary at the latest attestation.
285 #[serde(default)]
286 pub compliance: ComplianceStatus,
287
288 /// Pending signals (delivered, not yet handled).
289 #[serde(default)]
290 pub signal_queue: Vec<ProcessSignal>,
291
292 /// Standard K8s Conditions.
293 #[serde(default)]
294 pub conditions: Vec<ProcessCondition>,
295
296 /// Human-readable last status message.
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub message: Option<String>,
299
300 /// Exit code (only set on Failed / Reaped).
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub exit_code: Option<i32>,
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use crate::classification::{ConvergencePointType, SubstrateType};
309 use crate::intent::NixIntent;
310
311 #[test]
312 fn minimal_spec_serializes() {
313 let spec = ProcessSpec {
314 identity: IdentitySpec::default(),
315 classification: Classification {
316 point_type: ConvergencePointType::Gate,
317 substrate: SubstrateType::Observability,
318 horizon: Default::default(),
319 calm: Default::default(),
320 data_classification: Default::default(),
321 },
322 intent: Intent {
323 nix: Some(NixIntent {
324 flake_ref: "github:pleme-io/k8s".into(),
325 attribute: "obs".into(),
326 system: None,
327 attic_cache: None,
328 extra_args: vec![],
329 delegate_to_nix_build: false,
330 }),
331 ..Intent::default()
332 },
333 boundary: Default::default(),
334 compliance: Default::default(),
335 depends_on: vec![],
336 signals: Default::default(),
337 lifetime: Default::default(),
338 routing: None,
339 encapsulates: None,
340 suspended: false,
341 };
342 let yaml = serde_yaml::to_string(&spec).unwrap();
343 assert!(yaml.contains("pointType: Gate"));
344 assert!(yaml.contains("substrate: Observability"));
345 assert!(yaml.contains("flakeRef: github:pleme-io/k8s"));
346 }
347
348 // ─── Process::coordinates_or_defaults substrate pins ────────────────
349 //
350 // Pins the (namespace, name) coordinate-primitive family on the
351 // (metadata slot × fallback shape) axis. Fail-before-pass-after
352 // granularity: a regression that flipped either fallback string,
353 // swapped the return-tuple axis order, or dropped the
354 // `Option::as_deref` unwrap surfaces here rather than as silent
355 // drift at every downstream annotation writer / claim-arbiter row
356 // builder / render owner-metadata seed.
357
358 fn empty_spec() -> ProcessSpec {
359 ProcessSpec {
360 identity: IdentitySpec::default(),
361 classification: Classification {
362 point_type: ConvergencePointType::Gate,
363 substrate: SubstrateType::Compute,
364 horizon: Default::default(),
365 calm: Default::default(),
366 data_classification: Default::default(),
367 },
368 intent: Intent::default(),
369 boundary: Default::default(),
370 compliance: Default::default(),
371 depends_on: vec![],
372 signals: Default::default(),
373 lifetime: Default::default(),
374 routing: None,
375 encapsulates: None,
376 suspended: false,
377 }
378 }
379
380 #[test]
381 fn default_namespace_constant_is_k8s_canonical_default() {
382 // Pins the load-bearing convention that this primitive's
383 // namespace fallback matches K8s's own implicit-namespace
384 // spelling. A regression that renamed this to "kube-system"
385 // or any other K8s-reserved name would silently misroute
386 // every downstream namespaced-Api call on a Process without
387 // a metadata.namespace.
388 assert_eq!(Process::DEFAULT_NAMESPACE, "default");
389 }
390
391 #[test]
392 fn unnamed_placeholder_constant_matches_prior_annotation_writer_fallback() {
393 // Pins the load-bearing convention that this primitive's name
394 // fallback matches the exact spelling every annotation writer
395 // (tatara-reconciler::ssapply::inject_annotations,
396 // tatara-reconciler::render::render, and
397 // tatara-reconciler::table_controller's claim-row builder)
398 // was hand-authoring pre-lift ("unnamed", NOT "<unnamed>" or
399 // ""). A regression that renamed this would break the
400 // annotation-writer / claim-arbiter grep contract silently.
401 assert_eq!(Process::UNNAMED_PLACEHOLDER, "unnamed");
402 }
403
404 #[test]
405 fn namespace_or_default_falls_back_when_metadata_namespace_is_none() {
406 let mut p = Process::new("some-proc", empty_spec());
407 p.metadata.namespace = None;
408 assert_eq!(p.namespace_or_default(), Process::DEFAULT_NAMESPACE);
409 }
410
411 #[test]
412 fn namespace_or_default_returns_metadata_slice_when_some() {
413 let mut p = Process::new("some-proc", empty_spec());
414 p.metadata.namespace = Some("prod-app".into());
415 assert_eq!(p.namespace_or_default(), "prod-app");
416 }
417
418 #[test]
419 fn name_or_placeholder_falls_back_when_metadata_name_is_none() {
420 let mut p = Process::new("real-name", empty_spec());
421 p.metadata.name = None;
422 assert_eq!(p.name_or_placeholder(), Process::UNNAMED_PLACEHOLDER);
423 }
424
425 #[test]
426 fn name_or_placeholder_returns_metadata_slice_when_some() {
427 let p = Process::new("api-gateway", empty_spec());
428 assert_eq!(p.name_or_placeholder(), "api-gateway");
429 }
430
431 #[test]
432 fn coordinates_or_defaults_composes_both_halves() {
433 // Both slots present — returns metadata slices in
434 // (namespace, name) axis order.
435 let mut p = Process::new("api", empty_spec());
436 p.metadata.namespace = Some("staging".into());
437 assert_eq!(p.coordinates_or_defaults(), ("staging", "api"));
438 }
439
440 #[test]
441 fn coordinates_or_defaults_falls_back_on_both_slots() {
442 // Both slots None — returns (DEFAULT_NAMESPACE,
443 // UNNAMED_PLACEHOLDER) in axis order.
444 let mut p = Process::new("scratch", empty_spec());
445 p.metadata.name = None;
446 p.metadata.namespace = None;
447 assert_eq!(
448 p.coordinates_or_defaults(),
449 (Process::DEFAULT_NAMESPACE, Process::UNNAMED_PLACEHOLDER)
450 );
451 }
452
453 #[test]
454 fn coordinates_or_defaults_mixes_slotted_and_fallback_halves() {
455 // Namespace set, name missing — the (namespace, name) tuple
456 // pins each half independently. A regression that returned
457 // BOTH fallbacks when EITHER metadata slot was None would
458 // surface here rather than at every downstream reader.
459 let mut p = Process::new("kept-name", empty_spec());
460 p.metadata.namespace = Some("prod".into());
461 assert_eq!(p.coordinates_or_defaults(), ("prod", "kept-name"));
462
463 // Name set, namespace missing — the peer corner.
464 let mut q = Process::new("api", empty_spec());
465 q.metadata.namespace = None;
466 assert_eq!(
467 q.coordinates_or_defaults(),
468 (Process::DEFAULT_NAMESPACE, "api")
469 );
470 }
471
472 // ─── Process::owned_coordinates_or_err substrate pins ──────────────
473 //
474 // Pins the owned + name-required peer of the coordinate-primitive
475 // family on the (return-form × name gate) axis pair. Fail-before-
476 // pass-after granularity: a regression that flipped the namespace
477 // fallback string, dropped the `Option::clone` unwrap, changed the
478 // return-tuple axis order, or altered the "Process has no
479 // metadata.name" error wording surfaces here rather than as silent
480 // drift at every pre-lift caller (10 sites in
481 // `tatara-reconciler::phase_machine` + 2 sites in
482 // `tatara-reconciler::signals` pre-lift).
483
484 #[test]
485 fn owned_coordinates_or_err_returns_owned_strings_when_both_slots_present() {
486 // Happy path — both slots populated, method returns owned
487 // Strings in (namespace, name) axis order.
488 let mut p = Process::new("api-gateway", empty_spec());
489 p.metadata.namespace = Some("prod-app".into());
490 let (ns, name) = p.owned_coordinates_or_err().unwrap();
491 assert_eq!(ns, "prod-app");
492 assert_eq!(name, "api-gateway");
493 // Ownership pin: type inference above binds ns/name as
494 // owned Strings — a regression that returned &str would
495 // fail to compile at the following .push() call. This
496 // holds the "owned" half of the primitive's contract.
497 let mut owned_ns = ns;
498 owned_ns.push_str("-mutated");
499 assert_eq!(owned_ns, "prod-app-mutated");
500 }
501
502 #[test]
503 fn owned_coordinates_or_err_falls_back_on_namespace_but_returns_owned_name() {
504 // Namespace absent → DEFAULT_NAMESPACE. Name present → owned.
505 let p = Process::new("api", empty_spec());
506 // Process::new leaves metadata.namespace = None by default.
507 let (ns, name) = p.owned_coordinates_or_err().unwrap();
508 assert_eq!(ns, Process::DEFAULT_NAMESPACE);
509 assert_eq!(name, "api");
510 }
511
512 #[test]
513 fn owned_coordinates_or_err_errors_when_metadata_name_absent_regardless_of_namespace() {
514 // Name absent → Err, REGARDLESS of whether the namespace is
515 // populated. The name gate is strictly on `metadata.name` and
516 // does NOT fall back to `Self::UNNAMED_PLACEHOLDER` (that
517 // fallback is on the peer `coordinates_or_defaults`, which
518 // exists precisely for consumers that can tolerate a
519 // display placeholder).
520 for ns_slot in [None, Some("prod".to_string())] {
521 let mut p = Process::new("scratch", empty_spec());
522 p.metadata.name = None;
523 p.metadata.namespace = ns_slot.clone();
524 let err = p.owned_coordinates_or_err().unwrap_err();
525 assert!(
526 err.to_string().contains("metadata.name"),
527 "err on missing name (ns={ns_slot:?}) should mention metadata.name; got {err}"
528 );
529 }
530 }
531
532 #[test]
533 fn owned_coordinates_or_err_error_message_matches_pre_lift_reconciler_wording() {
534 // Load-bearing wording pin — every pre-lift `tatara-reconciler`
535 // helper (`phase_machine::namespace_and_name`,
536 // `signals::ingest`, `signals::consume_effect`) errored with
537 // EXACTLY this wording. Post-lift the substrate owner produces
538 // the same wording so log-line / test greps that anchored on
539 // it keep matching, and no operator-visible message drift
540 // lands as a side effect of the substrate move.
541 let mut p = Process::new("scratch", empty_spec());
542 p.metadata.name = None;
543 let err = p.owned_coordinates_or_err().unwrap_err();
544 assert_eq!(err.to_string(), "Process has no metadata.name");
545 }
546
547 #[test]
548 fn owned_coordinates_or_err_namespace_fallback_matches_default_namespace_const() {
549 // Byte-identity pin between the owned form's namespace
550 // fallback and the workspace-wide `DEFAULT_NAMESPACE` const.
551 // A regression that spelled this fallback as any other
552 // string ("kube-system", "", "default-ns") would silently
553 // misroute every downstream namespaced-Api call on a
554 // Process without a metadata.namespace — surfaces here
555 // rather than at every kube-rs API caller.
556 let mut p = Process::new("api", empty_spec());
557 p.metadata.namespace = None;
558 let (ns, _) = p.owned_coordinates_or_err().unwrap();
559 assert_eq!(ns, Process::DEFAULT_NAMESPACE);
560 }
561
562 #[test]
563 fn owned_coordinates_or_err_matches_pre_lift_reconciler_helper_shape() {
564 // Byte-identical parity pin between the owned + name-required
565 // primitive here and the pre-lift `tatara-reconciler` helper
566 // shape — the exact 2-slot unwrap chain each pre-lift caller
567 // spelled by hand:
568 //
569 // let ns = p.metadata.namespace.clone().unwrap_or_else(|| "default".into());
570 // let name = p.metadata.name.clone().ok_or_else(|| anyhow!(...))?;
571 // Ok((ns, name))
572 //
573 // Sweeps every corner every callsite plausibly encounters
574 // (both slots present, namespace absent, name absent, both
575 // absent). A regression that inserted a normalization step
576 // at the primitive that the pre-lift chain does NOT apply —
577 // or vice versa — surfaces here rather than as silent drift
578 // between the 12 pre-lift consumer callsites and the ONE
579 // substrate owner they now route through.
580 fn pre_lift(p: &Process) -> anyhow::Result<(String, String)> {
581 let ns = p
582 .metadata
583 .namespace
584 .clone()
585 .unwrap_or_else(|| "default".into());
586 let name = p
587 .metadata
588 .name
589 .clone()
590 .ok_or_else(|| anyhow::anyhow!("Process has no metadata.name"))?;
591 Ok((ns, name))
592 }
593 // Both present.
594 let mut p = Process::new("api", empty_spec());
595 p.metadata.namespace = Some("prod".into());
596 assert_eq!(p.owned_coordinates_or_err().unwrap(), pre_lift(&p).unwrap());
597 // Namespace absent.
598 let p = Process::new("api", empty_spec());
599 assert_eq!(p.owned_coordinates_or_err().unwrap(), pre_lift(&p).unwrap());
600 // Name absent → both variants error with the same wording.
601 let mut p = Process::new("api", empty_spec());
602 p.metadata.name = None;
603 p.metadata.namespace = Some("prod".into());
604 assert_eq!(
605 p.owned_coordinates_or_err().unwrap_err().to_string(),
606 pre_lift(&p).unwrap_err().to_string(),
607 );
608 // Both absent → still errors on the name gate.
609 let mut p = Process::new("api", empty_spec());
610 p.metadata.name = None;
611 p.metadata.namespace = None;
612 assert_eq!(
613 p.owned_coordinates_or_err().unwrap_err().to_string(),
614 pre_lift(&p).unwrap_err().to_string(),
615 );
616 }
617
618 #[test]
619 fn owned_coordinates_or_err_axis_order_matches_coordinates_or_defaults() {
620 // Cross-primitive coherence pin between the owned + name-
621 // required form and the borrow + name-defaulted peer:
622 // (namespace, name) axis order is IDENTICAL across both
623 // return-forms. A regression that swapped the tuple slots on
624 // only ONE of the two primitives would silently misroute
625 // every consumer that picked between the two forms based on
626 // its callsite's ownership needs. The pin re-reads both
627 // primitives at test time so the equality holds iff both
628 // live paths are the current implementation.
629 let mut p = Process::new("app", empty_spec());
630 p.metadata.namespace = Some("infra".into());
631 let (borrow_ns, borrow_name) = p.coordinates_or_defaults();
632 let (owned_ns, owned_name) = p.owned_coordinates_or_err().unwrap();
633 assert_eq!(owned_ns, borrow_ns);
634 assert_eq!(owned_name, borrow_name);
635 // Explicit slot labels — pins the (namespace, name) axis
636 // order as opposed to (name, namespace).
637 assert_eq!(owned_ns, "infra"); // NOT "app"
638 assert_eq!(owned_name, "app"); // NOT "infra"
639 }
640
641 #[test]
642 fn coordinates_or_defaults_axis_order_matches_qualified_process_ref() {
643 // Pins the load-bearing convention that the return-tuple
644 // axis order is (namespace, name) — the exact positional
645 // argument order the substrate's paired-composer primitive
646 // `tatara_reconciler::ssapply::qualified_process_ref(ns,
647 // name)` consumes. A regression that swapped the tuple
648 // slots would silently misroute every annotation writer /
649 // claim-arbiter row / owner-metadata seed built by feeding
650 // this pair into the composer — every downstream `<ns>/
651 // <name>` grep would suddenly see `<name>/<ns>`. The test
652 // verifies the tuple's first slot is what a hand-authored
653 // `.metadata.namespace.as_deref()...` produced pre-lift, and
654 // the second slot is what `.metadata.name.as_deref()...`
655 // produced.
656 let mut p = Process::new("app", empty_spec());
657 p.metadata.namespace = Some("infra".into());
658 let (ns, name) = p.coordinates_or_defaults();
659 assert_eq!(ns, "infra"); // NOT "app"
660 assert_eq!(name, "app"); // NOT "infra"
661 }
662}