Skip to main content

tatara_process/
routing.rs

1//! `RoutingSpec` — declared DNS + Ingress edges this Process exposes.
2//!
3//! The substrate move: every Process can declare hostnames at which
4//! it answers. The reconciler emits one `networking.k8s.io/v1`
5//! Ingress + one `externaldns.k8s.io/v1alpha1` DNSEndpoint per
6//! entry, owned by the Process via ownerRefs (cascade-delete on
7//! Reaped). DNS records are declarative — the Process IS the source
8//! of truth for `${app}.${eph_id}.${cluster}.${location}.${domain}`.
9//!
10//! Two hostname forms:
11//!
12//! 1. **Per-instance** — `${app}.${eph_id}.${cluster}.${loc}.${domain}`.
13//!    The `eph_id` segment is the `hostnames[i].instance` value when
14//!    set, or the BLAKE3:8 short-hash of the Process's canonical
15//!    spec when unset. Stable for the lifetime of the spec; new
16//!    spec content ⇒ new hash ⇒ new slot.
17//!
18//! 2. **Stable claim** — `${app}.${cluster}.${loc}.${domain}` (no
19//!    `eph_id` segment). Emitted iff `stable_name_claim: true` AND
20//!    this Process currently holds the ProcessTable.claims entry
21//!    for `(cluster, app)`. The claim arbiter handles atomic
22//!    transfer when the holder fails.
23//!
24//! Lisp authoring:
25//! ```lisp
26//! :routing (:hostnames ((:app "api" :instance "demo-prod")
27//!                       (:app "gateway"))
28//!           :backend   (:service "demo-app-gateway"
29//!                       :port    8000)
30//!           :stable-name-claim #t
31//!           :priority           100)
32//! ```
33
34use schemars::JsonSchema;
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use tatara_lisp::DeriveTataraDomain;
38
39/// Declared external edges (DNS + Ingress) this Process exposes.
40///
41/// Optional on `ProcessSpec` — None means the Process is in-cluster-
42/// only, matching today's default behavior. The reconciler only
43/// emits routing artifacts when this slot is populated.
44#[derive(DeriveTataraDomain, Clone, Debug, Serialize, Deserialize, JsonSchema)]
45#[serde(rename_all = "camelCase")]
46#[tatara(keyword = "defrouting")]
47pub struct RoutingSpec {
48    /// Hostnames this Process answers on. Empty list is legal but
49    /// nonsensical (no Ingress, no DNS) — operators should drop the
50    /// `routing` slot entirely instead. The reconciler warns on
51    /// empty hostnames.
52    #[serde(default)]
53    pub hostnames: Vec<RoutingHostname>,
54
55    /// Single backend Service every hostname routes to. Per-hostname
56    /// backends are a future extension; v1 keeps the simple shape.
57    pub backend: RoutingBackend,
58
59    /// When true, additionally emit the *unprefixed* form of every
60    /// hostname (`${app}.${cluster}.${loc}.${domain}` — no
61    /// `eph_id` segment) iff this Process currently holds the
62    /// ProcessTable claim for `(cluster, app)`. At most one Process
63    /// per (cluster, app) holds the claim.
64    #[serde(default)]
65    pub stable_name_claim: bool,
66
67    /// Claim arbitration priority. Higher wins. Ties broken by
68    /// oldest `creationTimestamp`. Negative values legal (signals
69    /// "prefer not to hold the claim"). Default 0.
70    #[serde(default)]
71    pub priority: i32,
72}
73
74/// One entry in `RoutingSpec.hostnames`.
75///
76/// Emitted FQDN: `${app}.${ephemeral_id}.${cluster}.${location}.${domain}`
77/// where:
78/// * `app` and (optional) `instance` come from this struct;
79/// * `cluster` falls back to reconciler-config when unset;
80/// * `location` and `domain` are reconciler-config (from
81///   `nix/lib/fleet-domains.nix`).
82#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
83#[serde(rename_all = "camelCase")]
84pub struct RoutingHostname {
85    /// Application slot — `api`, `gateway`, `web`, etc.
86    /// Must be a valid DNS label (RFC 1123): lowercase alpha-num
87    /// + hyphen, 1–63 chars, no leading/trailing hyphen. The
88    /// reconciler validates this at the boundary.
89    pub app: String,
90
91    /// Named instance segment. When `Some("demo-prod")` the FQDN
92    /// reads `${app}.demo-prod.${cluster}.…`. When `None` the
93    /// reconciler substitutes `blake3(canonical_spec)[:8]` —
94    /// deterministic per-spec, changes when the spec changes.
95    ///
96    /// Must be a valid DNS label when set.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub instance: Option<String>,
99
100    /// Cluster override. Empty/None ⇒ reconciler-config default
101    /// (e.g., `pleme-dev`). Used for cross-cluster routing rules,
102    /// rare in practice.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub cluster: Option<String>,
105}
106
107/// Backend Service the FQDN's Ingress routes traffic to.
108#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[serde(rename_all = "camelCase")]
110pub struct RoutingBackend {
111    /// In-cluster Service name (same namespace as the Process).
112    pub service: String,
113
114    /// Port number on the Service to route to.
115    pub port: u16,
116
117    /// `ClusterIssuer` name for TLS. None ⇒ reconciler-config
118    /// default (typically `letsencrypt-prod` or the cluster's
119    /// SPIRE-issuing issuer).
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub tls_issuer: Option<String>,
122
123    /// Annotations stamped on every emitted Ingress. Common keys:
124    /// `nginx.ingress.kubernetes.io/rate-limit`, `nginx.ingress.
125    /// kubernetes.io/proxy-body-size`. The reconciler MERGES these
126    /// with its own annotations; conflict ⇒ this map wins.
127    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
128    pub ingress_annotations: BTreeMap<String, String>,
129}
130
131impl RoutingSpec {
132    /// True iff at least one hostname is declared. The reconciler
133    /// uses this to short-circuit: empty routing ⇒ no emission.
134    pub fn has_hostnames(&self) -> bool {
135        !self.hostnames.is_empty()
136    }
137
138    /// Total count of FQDNs this Process will emit:
139    /// `hostnames.len()` per-instance + `hostnames.len()` stable
140    /// when the claim is held.
141    pub fn emitted_fqdn_count(&self, claim_held: bool) -> usize {
142        self.hostnames.len() * if claim_held { 2 } else { 1 }
143    }
144
145    /// Declared routing form for this Process — the typed projection
146    /// over the [`Self::stable_name_claim`] bool through the ONE
147    /// substrate composer [`RoutingForm::from_is_stable`]. Every
148    /// downstream axis (the [`crate::annotations::ROUTING_FORM`]
149    /// annotation / label the reconciler stamps, the
150    /// `routing-form-<kind>` require-tag prefix family in
151    /// `tatara-check`, any future audit dispatcher walking
152    /// [`RoutingForm::ALL`]) reads THIS ONE projection so a shift
153    /// in how "which routing form does this spec declare intent
154    /// for" is derived lands at ONE site.
155    ///
156    /// Semantics — DECLARED intent, not RESOLVED emission: this is
157    /// what the operator authored on the spec. The reconciler still
158    /// gates on the ProcessTable claim before emitting the stable
159    /// FQDN form — a `stable_name_claim: true` spec that loses the
160    /// claim to a higher-priority peer still `form() == Stable` at
161    /// this site (the *declared* intent), even though the
162    /// runtime-effective emission is `Instance` on that reconcile
163    /// tick. The `routing-form-<kind>` require-tag is intentionally
164    /// a spec-shape probe, not a runtime-status probe, so it stays
165    /// pinned to this projection.
166    ///
167    /// Peer to [`crate::classification::Classification::horizon_kind`] /
168    /// [`crate::classification::Classification::optimization_direction`]
169    /// on the "typed projection over a stored field on ONE spec
170    /// slot → closed-set discriminator" axis — both hide the raw
171    /// wire-form field behind ONE typed projection so a future
172    /// normalization (widening [`Self::stable_name_claim`] into a
173    /// typed enum with a third variant, canonicalizing across a
174    /// new `Gateway` form) lands at THIS ONE site and every
175    /// downstream consumer inherits the upgrade mechanically.
176    #[must_use]
177    pub const fn form(&self) -> RoutingForm {
178        RoutingForm::from_is_stable(self.stable_name_claim)
179    }
180
181    /// Scalar-carrier presence probe on the derived
182    /// [`Self::form`] projection — `true` iff this routing spec's
183    /// declared [`RoutingForm`] (as read through
184    /// [`RoutingForm::from_is_stable`] over the `stable_name_claim`
185    /// bool) matches the queried variant.
186    ///
187    /// The one-line collapse of the
188    /// `<r>.form() == kind` closure body lifted to ONE substrate
189    /// owner past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold —
190    /// the nineteenth closed-set-driven prefix family in
191    /// [`tatara-check`]'s point-domain require-tag classifier
192    /// (`routing-form-<kind>`) is the first workspace-wide consumer.
193    /// The shape is a peer of
194    /// [`crate::lifetime::EphemeralLifetime::has_teardown_policy`] on
195    /// the SAME (Option-parent × defaulted-scalar-child) corner of
196    /// the workspace-wide presence-probe algebra: the parent is
197    /// `Option<RoutingSpec>` on [`crate::crd::ProcessSpec::routing`]
198    /// (None short-circuits every kind), and the child is a scalar
199    /// derived from a `#[serde(default)]` bool (`stable_name_claim:
200    /// false` by default → `RoutingForm::Instance` by default).
201    ///
202    /// # Sibling scalar-carrier probes
203    ///
204    /// * [`crate::spec::SignalPolicy::has_sighup_strategy`] — required
205    ///   parent × defaulted scalar child (stored).
206    /// * [`crate::classification::Classification::has_calm`] /
207    ///   [`crate::classification::Classification::has_data_classification`]
208    ///   — required parent × defaulted scalar child (stored).
209    /// * [`crate::classification::Classification::has_horizon_kind`] /
210    ///   [`crate::classification::Classification::has_optimization_direction`]
211    ///   — required parent × nested-struct-scalar-child (stored).
212    /// * [`crate::lifetime::EphemeralLifetime::has_teardown_policy`]
213    ///   — Option parent × defaulted-scalar-child (stored).
214    /// * THIS — Option parent (`Option<RoutingSpec>` on
215    ///   [`crate::crd::ProcessSpec::routing`]) × defaulted-scalar-child
216    ///   ([`RoutingForm`] DERIVED from the defaulted-false bool
217    ///   `stable_name_claim`). Second occupant on the
218    ///   (Option-parent × defaulted-scalar-child) corner, and the
219    ///   FIRST occupant whose child is *derived* rather than
220    ///   *stored* — the shape composes through the ONE
221    ///   [`RoutingForm::from_is_stable`] projection so a future
222    ///   widening of the underlying `stable_name_claim` bool into a
223    ///   typed enum lands at [`RoutingForm::from_is_stable`] alone
224    ///   and every `has_form` consumer inherits the upgrade
225    ///   mechanically.
226    ///
227    /// # Semantics — DECLARED form, not RESOLVED emission
228    ///
229    /// `has_form(kind)` returns `true` iff `self.form() == kind`.
230    /// See [`Self::form`] for the "declared intent" vs
231    /// "runtime-effective emission" distinction — the probe is a
232    /// spec-shape probe, not a runtime-status probe, so a
233    /// `stable_name_claim: true` spec that loses the ProcessTable
234    /// claim still reads `has_form(Stable) == true` at this site.
235    /// The reconciler-side "actually emit stable FQDNs" gate lives
236    /// downstream at claim arbitration, not here.
237    ///
238    /// # Compounding
239    ///
240    /// A future third [`RoutingForm`] variant added to `ALL` (a
241    /// hypothetical `Gateway` for a future Gateway-API `HTTPRoute`
242    /// edge, distinct from both the per-instance and stable-claim
243    /// FQDN shapes) reaches this probe through ONE `ALL` entry +
244    /// one `as_str` arm + one `from_is_stable` widening alone, no
245    /// per-caller edit at the `routing-form-<kind>` require-tag
246    /// classifier and no per-consumer restatement of the
247    /// `spec.routing.as_ref().is_some_and(|r| r.form() == kind)`
248    /// closure body.
249    ///
250    /// Theory anchor: THEORY.md §II.1 invariant 5 — composition
251    /// preserves proofs; the scalar-carrier presence-probe body
252    /// lives at ONE substrate site so every downstream
253    /// (`routing-form-<kind>` require-tag family in tatara-check,
254    /// closed-set audit dispatchers, future variant additions on
255    /// [`RoutingForm`]) binds through the SAME `has(kind)` shape.
256    /// THEORY.md §VI.1 — generation over composition; a future
257    /// variant lands at ONE `ALL` entry + one `as_str` arm + one
258    /// `from_is_stable` widening on the closed set and the probe
259    /// picks it up mechanically without further per-consumer
260    /// edits.
261    #[must_use]
262    pub fn has_form(&self, kind: RoutingForm) -> bool {
263        self.form() == kind
264    }
265}
266
267/// Extension trait collapsing the Option-carrier arm on
268/// `Option<RoutingSpec>` — the ONE substrate primitive that owns the
269/// "collapse the parent `routing` Option-carrier before probing the
270/// inner spec's derived form" discipline the point-domain require-tag
271/// classifier in `tatara-reconciler::bin::tatara-check` composed by
272/// hand pre-lift at `("routing-form-", RoutingForm, |k| spec.routing
273/// .as_ref().is_some_and(|r| r.has_form(k)))`.
274///
275/// Peer to [`crate::encapsulates::EncapsulatesSpecOptionExt`] on the
276/// sibling `Option<EncapsulatesSpec>` slot of
277/// [`crate::crd::ProcessSpec`] — both extension traits live on
278/// `Option<<parent-spec>>`, both own the `.as_ref().is_some_and(|p|
279/// p.<probe>(k))` chain at ONE substrate site, and both return
280/// `false` on the outer `None` arm (a Process that DECLINED the
281/// respective surface — in-cluster-only for routing, unencapsulated
282/// for encapsulates). Together they close the pattern's blast radius
283/// across the two Option-parent presence-probe families whose parent
284/// is `Option<<inner-spec>>` directly on [`crate::crd::ProcessSpec`],
285/// so every current + future presence probe on THOSE parents inherits
286/// the collapse mechanically through the SAME trait-method shape.
287///
288/// Sibling Option-parent collapse whose parent is instead a compound
289/// projection: [`crate::lifetime::Lifetime::ephemeral_exports`] —
290/// walks `Lifetime → resolved_ephemeral() → exports` and returns
291/// `&[]` on the `None` arm (as a slice, not a bool) so the six
292/// slice-level `ExportSpecSliceExt::*` probes compose without a
293/// bespoke Option-arm at the caller. This trait is the direct
294/// analogue for the Option-parent whose inner spec ITSELF carries
295/// the probe (no intermediate slice).
296///
297/// # Semantics — COLLAPSED-NONE vs DELEGATED-SOME
298///
299/// * `None` (an in-cluster-only Process that DECLINED the routing
300///   surface entirely) → returns `false` for EVERY [`RoutingForm`]
301///   kind. The absent-carrier arm is NOT the derived
302///   [`RoutingForm::Instance`] default that a POPULATED
303///   [`RoutingSpec`] with `stable_name_claim: false` (its serde
304///   default) would publish — the derived-scalar default only fires
305///   when the operator OPTED INTO the routing surface and left the
306///   discriminating slot at its default, not when they declined the
307///   surface entirely.
308/// * `Some(r)` → delegates to [`RoutingSpec::has_form`] byte-
309///   identically. The populated arm is the ONLY behavioral surface
310///   the collapse preserves through the SAME
311///   [`RoutingForm::from_is_stable`] projection over the
312///   `stable_name_claim` bool.
313///
314/// # Compounding
315///
316/// A future second presence-probe axis on [`RoutingSpec`] (a
317/// hypothetical `has_backend_kind` on a widened
318/// [`RoutingBackend`] closed set, a
319/// `has_priority_tier(TierKind)` reaching through a typed
320/// projection over the raw `priority: i32` slot, a future
321/// discriminator on a widened `stable_name_claim` typed enum) lands
322/// as ONE more method on this trait + ONE more prefix-table row in
323/// the classifier — no per-caller `.as_ref().is_some_and(...)`
324/// restatement, no per-caller `spec.routing.as_ref()` walk. A future
325/// diagnostic shift on the Option-carrier collapse (surfacing
326/// "routing declined" as a distinct near-miss from "routing set but
327/// axis absent") reaches THIS ONE substrate owner, and every present
328/// or future require-tag family on the SAME
329/// [`crate::crd::ProcessSpec::routing`] parent inherits the shift by
330/// construction.
331///
332/// Theory anchor: THEORY.md §II.1 invariant 5 (composition preserves
333/// proofs — the Option-carrier collapse lives at ONE substrate site
334/// so every downstream `routing-<axis>` require-tag family binds
335/// through the SAME shape). THEORY.md §VI.1 (generation over
336/// composition — a new probe on [`RoutingSpec`] reaches this trait
337/// through a peer method without a bespoke Option-arm at the caller).
338///
339/// Pinned by
340/// [`tests::routing_spec_option_ext_has_form_returns_false_on_none_for_every_kind`]
341/// and
342/// [`tests::routing_spec_option_ext_has_form_matches_inner_probe_when_present`].
343pub trait RoutingSpecOptionExt {
344    /// True iff this `Option<RoutingSpec>` is `Some(r)` AND
345    /// [`RoutingSpec::has_form`] on the inner spec answers `true`
346    /// for the given [`RoutingForm`]. Returns `false` on `None` (an
347    /// in-cluster-only Process that declined the routing surface
348    /// entirely) — INCLUDING for the derived default
349    /// [`RoutingForm::Instance`], because the operator DECLINED the
350    /// routing surface rather than defaulting into it.
351    fn has_form(&self, kind: RoutingForm) -> bool;
352}
353
354impl RoutingSpecOptionExt for Option<RoutingSpec> {
355    fn has_form(&self, kind: RoutingForm) -> bool {
356        self.as_ref().is_some_and(|r| r.has_form(kind))
357    }
358}
359
360impl RoutingHostname {
361    /// True iff this entry resolves to a named slot (vs content-hash).
362    pub fn is_named(&self) -> bool {
363        self.instance.as_deref().is_some_and(|s| !s.is_empty())
364    }
365
366    /// Cluster override slice with a caller-supplied per-config
367    /// fallback applied — the ONE-line collapse of the paired
368    /// `self.cluster.as_deref().unwrap_or(fallback)` incantation the
369    /// reconciler's FQDN composer + stable-claim group-key composer
370    /// both spelled by hand pre-lift.
371    ///
372    /// Pre-lift the projection was hand-authored at TWO sites past
373    /// the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold in
374    /// `tatara-reconciler`, each walking the SAME borrow-form
375    /// `Option<String>` slot × per-config-fallback shape:
376    /// * `render::render_routing` — per-instance FQDN composer seed
377    ///   for [`crate::hostname::fmt_fqdn`], keyed on the cluster
378    ///   segment.
379    /// * `table_controller::stable_name_group_key` — claim-arbiter
380    ///   `(cluster, app)` group-key seed, keyed on the cluster
381    ///   segment.
382    ///
383    /// Both sites walked the SAME projection: pull the borrow-form
384    /// `.cluster.as_deref()` slot, sink an absent slot to the
385    /// per-config fallback the caller threads in from
386    /// `Context.config.cluster`. Post-lift both consumers read
387    /// `hostname.cluster_or(cfg_cluster)` — the projection sits at
388    /// ONE substrate owner, so a future normalization (a case-fold
389    /// pass, an empty-string-to-fallback promotion, a cross-cluster
390    /// alias resolver, a per-fleet cluster-name canonicalization)
391    /// lands here exactly once and every consumer (FQDN composer,
392    /// claim-arbiter group key, and any future edge whose downstream
393    /// keys on the cluster segment) inherits the upgrade
394    /// mechanically.
395    ///
396    /// Peer to [`Self::is_named`] on the (Option<String> slot ×
397    /// fallback shape) axis pair — both live on `RoutingHostname`
398    /// and hide the missing-slot corner behind ONE substrate
399    /// primitive; both preserve the borrow-form return, so downstream
400    /// composers thread the slice without a `.to_string()` step.
401    ///
402    /// Semantics: an explicit `Some("")` returns the empty string
403    /// (matching the pre-lift `.as_deref().unwrap_or(fallback)`
404    /// chain's behavior). Callers whose downstream rejects an
405    /// empty cluster segment must gate on that separately —
406    /// [`crate::hostname::fmt_fqdn`]'s validator does so
407    /// automatically via [`crate::hostname::HostnameError::
408    /// InvalidLabel`].
409    pub fn cluster_or<'a>(&'a self, fallback: &'a str) -> &'a str {
410        self.cluster.as_deref().unwrap_or(fallback)
411    }
412
413    /// Compose a [`RoutingHostname`] pinned to the "named-slot,
414    /// per-config cluster fallback" shape (`instance: Some(<instance>)`,
415    /// `cluster: None`) — the ONE substrate primitive owning the
416    /// 3-slot `RoutingHostname { app, instance: Some(<instance>),
417    /// cluster: None }` fixture literal every consumer restated by
418    /// hand pre-lift.
419    ///
420    /// Pre-lift the same 3-slot chain (`app: <s>.into()`, `instance:
421    /// Some(<s>.into())`, `cluster: None`) was hand-authored at TEN
422    /// workspace-wide sites past the ★★ PRIME-DIRECTIVE ≥ 2
423    /// duplication threshold, EVERY one of them the "named instance
424    /// segment, cluster-inherits-from-config" shape:
425    ///
426    /// * `tatara-process::hostname` — two sites: the `resolve_named_slot_wins`
427    ///   fixture plus the `end_to_end_named_and_unnamed_for_same_process`
428    ///   named-arm fixture.
429    /// * `tatara-process::routing` — four sites: the `demo_routing` seed
430    ///   (two hostnames), the `hostname_is_named_when_instance_nonempty`
431    ///   populated-instance pin, and the `cluster_or_borrow_form_return_shape_matches_fmt_fqdn_arg_shape`
432    ///   FQDN-composer parity pin.
433    /// * `tatara-reconciler::edges` — two sites: the `api_hostname`
434    ///   test fixture plus the `routing_edge_labels_stamps_app_slot_from_hostname`
435    ///   APP-slot pin (which stamps `"gateway"` instead of `"api"`).
436    /// * `tatara-reconciler::render` — two sites: the `two_hostname_routing`
437    ///   seed's `api` + `gateway` hostname pair.
438    ///
439    /// Post-lift every callsite reads `RoutingHostname::instanced(<app>,
440    /// <instance>)` and the three-slot struct's `cluster` slot stays
441    /// owned by the ONE substrate site — the per-config-cluster
442    /// fallback resolved through [`Self::cluster_or`] at read time
443    /// stays the ONLY axis a cluster override travels through, so a
444    /// future normalization (a per-cluster canonicalization, a
445    /// cross-cluster alias resolver, a claim-arbiter fallback swap)
446    /// lands here exactly once and every consumer inherits the
447    /// upgrade mechanically. The `impl Into<String>` bound on both
448    /// positional args accepts every pre-lift caller shape verbatim
449    /// — `&'static str` literals, owned `String` values, and
450    /// `.into()`-terminated chains alike — without a per-site
451    /// coercion.
452    ///
453    /// Peer to [`Self::content_hashed`] on the (instance slot ×
454    /// cluster slot) axis pair: both live on `RoutingHostname` and
455    /// hide the pair's "per-config cluster fallback" corner behind
456    /// ONE substrate primitive; [`Self::instanced`] fills the
457    /// `Some(<name>)` arm of the `instance` slot, [`Self::content_hashed`]
458    /// fills the `None` arm.
459    ///
460    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
461    /// the `RoutingHostname { app, instance: Some(<i>), cluster: None }`
462    /// fixture literal recurred at ten hand-authored sites past the
463    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
464    /// ONE owner here). THEORY.md §II.1 invariant 5 (composition
465    /// preserves proofs — a regression that drifted the default-
466    /// cluster sentinel from `None` to a hardcoded string, or
467    /// reordered the three struct slots, surfaces at the
468    /// `instanced_composes_byte_identical_to_pre_lift_literal_across_every_app_instance_pair`
469    /// pin below rather than as silent skew at every downstream
470    /// fixture).
471    #[must_use]
472    pub fn instanced(app: impl Into<String>, instance: impl Into<String>) -> Self {
473        Self {
474            app: app.into(),
475            instance: Some(instance.into()),
476            cluster: None,
477        }
478    }
479
480    /// Compose a [`RoutingHostname`] pinned to the "content-hash
481    /// anonymous, per-config cluster fallback" shape (`instance: None`,
482    /// `cluster: None`) — the ONE substrate primitive owning the
483    /// 3-slot `RoutingHostname { app, instance: None, cluster: None }`
484    /// fixture literal every consumer restated by hand pre-lift.
485    ///
486    /// The `instance: None` slot instructs the reconciler's
487    /// [`crate::hostname::resolve_ephemeral_id`] to substitute
488    /// `blake3(canonical_spec)[:8]` — the content-hashed FQDN form
489    /// documented on [`RoutingHostname`]. Pre-lift the same 3-slot
490    /// chain (`app: <s>.into()`, `instance: None`, `cluster: None`)
491    /// was hand-authored at NINE workspace-wide sites past the ★★
492    /// PRIME-DIRECTIVE ≥ 2 duplication threshold, EVERY one of them
493    /// the "unnamed instance, cluster-inherits-from-config" shape:
494    ///
495    /// * `tatara-process::hostname` — two sites: the `resolve_unset_named_falls_back`
496    ///   fixture plus the `end_to_end_named_and_unnamed_for_same_process`
497    ///   anon-arm fixture.
498    /// * `tatara-process::routing` — six sites: the `h_anon` pin, the
499    ///   `cluster_or_falls_back_to_caller_string_when_cluster_is_none`
500    ///   fallback pin, and four more `cluster_or` / round-trip fixtures.
501    /// * `tatara-reconciler::render` — one site: the
502    ///   `anonymous_hostname_uses_content_hash` FQDN composer pin.
503    ///
504    /// Peer to [`Self::instanced`] on the (instance slot × cluster
505    /// slot) axis pair — [`Self::content_hashed`] fills the `None`
506    /// arm of the `instance` slot, [`Self::instanced`] fills the
507    /// `Some(<name>)` arm.
508    ///
509    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
510    /// the `RoutingHostname { app, instance: None, cluster: None }`
511    /// fixture literal recurred at nine hand-authored sites past the
512    /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
513    /// ONE owner here). THEORY.md §II.1 invariant 5.
514    #[must_use]
515    pub fn content_hashed(app: impl Into<String>) -> Self {
516        Self {
517            app: app.into(),
518            instance: None,
519            cluster: None,
520        }
521    }
522}
523
524impl RoutingBackend {
525    /// Compose a [`RoutingBackend`] pinned to the "reconciler-default
526    /// TLS issuer, no per-Ingress annotations" shape (`tls_issuer:
527    /// None`, `ingress_annotations: BTreeMap::new()`) — the ONE
528    /// substrate primitive owning the 4-slot `RoutingBackend { service,
529    /// port, tls_issuer: None, ingress_annotations: BTreeMap::new() }`
530    /// fixture literal every consumer restated by hand pre-lift.
531    ///
532    /// Pre-lift the same 4-slot chain (`service: <s>.into()`, `port:
533    /// <u16>`, `tls_issuer: None`, `ingress_annotations:
534    /// BTreeMap::new()`) was hand-authored at SEVEN workspace-wide
535    /// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
536    /// EVERY one of them the "default-issuer, empty-annotations"
537    /// shape:
538    ///
539    /// * `tatara-process::routing` — three sites: the `demo_routing`
540    ///   seed plus two round-trip pins (`empty_routing_resolves_no_hostnames`,
541    ///   `empty_fields_skip_serialize`).
542    /// * `tatara-reconciler::edges` — one site: the `api_backend`
543    ///   test fixture consumed by every `IngressEdge` / `DnsEndpointEdge`
544    ///   render pin.
545    /// * `tatara-reconciler::render` — three sites: the `two_hostname_routing`
546    ///   seed's backend, the `empty_hostnames_emits_nothing` pin, and
547    ///   the `anonymous_hostname_uses_content_hash` pin.
548    ///
549    /// Post-lift every callsite reads `RoutingBackend::plain(<service>,
550    /// <port>)` and the four-slot struct's `tls_issuer` +
551    /// `ingress_annotations` slots stay owned by the ONE substrate
552    /// site — a future normalization (a per-fleet default `ClusterIssuer`
553    /// selection, a per-fleet baseline Ingress annotation set, a
554    /// SPIRE-vs-Let's-Encrypt discriminator) lands here exactly once
555    /// and every consumer inherits the upgrade mechanically. The
556    /// `impl Into<String>` bound on `service` accepts every pre-lift
557    /// caller shape verbatim.
558    ///
559    /// Theory anchor: THEORY.md §VI.1 (generation over composition —
560    /// the `RoutingBackend { service, port, tls_issuer: None,
561    /// ingress_annotations: BTreeMap::new() }` fixture literal recurred
562    /// at seven hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
563    /// duplication trigger, and is lifted to ONE owner here).
564    /// THEORY.md §II.1 invariant 5 (composition preserves proofs —
565    /// a regression that drifted the default `tls_issuer` sentinel
566    /// from `None` to a hardcoded string, or reordered the four
567    /// struct slots, surfaces at the
568    /// `plain_composes_byte_identical_to_pre_lift_literal_across_every_service_port_pair`
569    /// pin below rather than as silent skew at every downstream
570    /// fixture).
571    #[must_use]
572    pub fn plain(service: impl Into<String>, port: u16) -> Self {
573        Self {
574            service: service.into(),
575            port,
576            tls_issuer: None,
577            ingress_annotations: BTreeMap::new(),
578        }
579    }
580}
581
582/// Wire-form value stamped at
583/// [`tatara_process::annotations::ROUTING_FORM`][
584/// crate::annotations::ROUTING_FORM] on every routing edge
585/// (Ingress + DNSEndpoint) — both the `annotations` axis and the
586/// `labels` axis carry it. Distinguishes the two FQDN shapes
587/// [`RoutingSpec`] emits: the per-instance form
588/// (`${app}.${eph_id}.${cluster}.${loc}.${domain}`) and the
589/// stable-claim form (`${app}.${cluster}.${loc}.${domain}`,
590/// emitted iff `stable_name_claim` is set and this Process
591/// currently holds the ProcessTable claim for `(cluster, app)`).
592///
593/// The pre-lift reconciler restated the same
594/// `if ctx.is_stable { "stable" } else { "instance" }` ternary at
595/// three call sites (an Ingress annotation, an Ingress label, a
596/// DNSEndpoint label) plus two byte-literal comparison sites in
597/// render tests. This typed enum turns that stringly-typed
598/// disjunction into a two-variant type with a single wire
599/// encoding, so a future edge kind (a Gateway API `HTTPRoute`, a
600/// `NetworkPolicy` edge) sourcing the axis through
601/// [`RoutingForm::from_is_stable`] + [`RoutingForm::as_str`]
602/// cannot drift from the two existing edges' spellings.
603#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, tatara_closed_set::DeriveClosedSet)]
604#[closed_set(via = "as_str", display, generate_unknown)]
605pub enum RoutingForm {
606    /// Emitted iff `RoutingSpec.stable_name_claim = true` AND
607    /// this Process currently holds the ProcessTable claim for
608    /// `(cluster, app)`. FQDN drops the `${eph_id}` segment.
609    Stable,
610    /// Emitted for every declared hostname entry (default). FQDN
611    /// carries the `${eph_id}` segment resolved by
612    /// [`crate::hostname::resolve_ephemeral_id`].
613    Instance,
614}
615
616impl RoutingForm {
617    /// The closed set of routing forms — single source of truth that
618    /// drives the `as_str` / Display / `FromStr` triad the
619    /// `#[derive(DeriveClosedSet)]` line generates and the typed
620    /// `from_is_stable` composer over `RoutingSpec.stable_name_claim`.
621    /// Adding a third variant (a hypothetical `Gateway` for a future
622    /// Gateway-API `HTTPRoute` edge, distinct from both the per-instance
623    /// and stable-claim FQDN shapes) lands at one `ALL` entry + one
624    /// `as_str` arm + one `from_is_stable` widening — exhaustively
625    /// checked by the compiler (the `[Self; 2]` array literal forces
626    /// the arity).
627    ///
628    /// Sibling closed-set lifts on the same `ProcessSpec` axis:
629    /// [`super::intent::IntentKind::ALL`], [`super::lifetime::LifetimeKind::ALL`],
630    /// [`crate::boundary::ConditionKind::ALL`],
631    /// [`crate::phase::ProcessPhase::ALL`],
632    /// [`crate::signal::ProcessSignal::ALL`],
633    /// [`crate::signal::SighupStrategy::ALL`],
634    /// [`crate::lifetime::TeardownPolicy::ALL`].
635    pub const ALL: [Self; 2] = [Self::Stable, Self::Instance];
636
637    /// Wire-form byte-shape stamped into the
638    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM]
639    /// annotation / label. The reconciler's stable-form filter
640    /// checks byte-identity against these two strings — a rename
641    /// here is a wire-form break every operator's kubectl-side
642    /// selector notices.
643    pub const fn as_str(self) -> &'static str {
644        match self {
645            RoutingForm::Stable => "stable",
646            RoutingForm::Instance => "instance",
647        }
648    }
649
650    /// Route the reconciler's `EdgeContext::is_stable` bool
651    /// through ONE composer so every downstream axis (the
652    /// stable-form suffix in edge resource names + the
653    /// [`ROUTING_FORM`][crate::annotations::ROUTING_FORM] value
654    /// on labels + annotations) shares the same source of truth.
655    pub const fn from_is_stable(is_stable: bool) -> Self {
656        if is_stable {
657            RoutingForm::Stable
658        } else {
659            RoutingForm::Instance
660        }
661    }
662}
663
664// `impl fmt::Display for RoutingForm` + `impl FromStr for RoutingForm`
665// + `impl tatara_lisp::ClosedSet for RoutingForm` + `pub struct
666// UnknownRoutingForm(pub String)` are generated by
667// `#[derive(tatara_closed_set::DeriveClosedSet)]` +
668// `#[closed_set(via = "as_str", display, generate_unknown)]` on the
669// enum declaration above. The inherent `as_str` projection stays
670// load-bearing — the byte-shape stamped into every routing edge's
671// [`crate::annotations::ROUTING_FORM`] annotation + label — while the
672// trait method `label` gives generic consumers a STABLE name across
673// the workspace-wide closed-set implementors.
674
675#[cfg(test)]
676mod tests {
677    use super::*;
678
679    fn demo_routing() -> RoutingSpec {
680        RoutingSpec {
681            hostnames: vec![
682                RoutingHostname::instanced("api", "demo-prod"),
683                RoutingHostname::instanced("gateway", "demo-prod"),
684            ],
685            backend: RoutingBackend::plain("demo-app-gateway", 8000),
686            stable_name_claim: true,
687            priority: 100,
688        }
689    }
690
691    #[test]
692    fn empty_routing_resolves_no_hostnames() {
693        let r = RoutingSpec {
694            hostnames: vec![],
695            backend: RoutingBackend::plain("x", 80),
696            stable_name_claim: false,
697            priority: 0,
698        };
699        assert!(!r.has_hostnames());
700        assert_eq!(r.emitted_fqdn_count(false), 0);
701        assert_eq!(r.emitted_fqdn_count(true), 0);
702    }
703
704    #[test]
705    fn fqdn_count_doubles_when_claim_held() {
706        let r = demo_routing();
707        assert_eq!(r.emitted_fqdn_count(false), 2);
708        assert_eq!(r.emitted_fqdn_count(true), 4);
709    }
710
711    #[test]
712    fn hostname_is_named_when_instance_nonempty() {
713        let h = RoutingHostname::instanced("x", "env-a");
714        assert!(h.is_named());
715
716        let h_anon = RoutingHostname::content_hashed("x");
717        assert!(!h_anon.is_named());
718
719        let h_empty = RoutingHostname {
720            app: "x".into(),
721            instance: Some(String::new()),
722            cluster: None,
723        };
724        assert!(!h_empty.is_named()); // empty string ⇒ unnamed
725    }
726
727    // ─── RoutingHostname::cluster_or substrate pins ──────────────
728    //
729    // The pre-lift reconciler restated the same
730    // `hostname.cluster.as_deref().unwrap_or(<cfg-cluster>)` chain at
731    // TWO callsites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
732    // trigger:
733    //   * render.rs::render_routing (line 561) — FQDN composer seed
734    //   * table_controller.rs::stable_name_group_key (line 101) —
735    //     claim-arbiter group-key seed
736    // Every corner of the paired projection is pinned here so a
737    // future normalization at the primitive lands with a
738    // fail-before-pass-after regression at THIS composer's pins
739    // rather than as silent operator-visible drift across the two
740    // callsite arms.
741
742    #[test]
743    fn cluster_or_returns_slot_when_cluster_is_populated() {
744        let h = RoutingHostname {
745            app: "api".into(),
746            instance: None,
747            cluster: Some("pleme-prod".into()),
748        };
749        assert_eq!(h.cluster_or("pleme-dev"), "pleme-prod");
750    }
751
752    #[test]
753    fn cluster_or_falls_back_to_caller_string_when_cluster_is_none() {
754        let h = RoutingHostname::content_hashed("api");
755        assert_eq!(h.cluster_or("pleme-dev"), "pleme-dev");
756    }
757
758    #[test]
759    fn cluster_or_returns_empty_slice_when_cluster_is_explicitly_empty_string() {
760        // Populated-empty short-circuit pin: `Some("")` is a
761        // populated slot for `as_deref().unwrap_or(...)`, so the
762        // fallback is NOT taken. The downstream FQDN composer's
763        // validator (`fmt_fqdn`) rejects the empty label with a
764        // typed `HostnameError::InvalidLabel`, not this primitive.
765        let h = RoutingHostname {
766            app: "api".into(),
767            instance: None,
768            cluster: Some(String::new()),
769        };
770        assert_eq!(h.cluster_or("pleme-dev"), "");
771    }
772
773    #[test]
774    fn cluster_or_is_a_pure_projection() {
775        // Two identical inputs → two identical outputs; no interior
776        // mutation or per-call hidden state.
777        let h = RoutingHostname {
778            app: "api".into(),
779            instance: Some("demo-prod".into()),
780            cluster: Some("pleme-prod".into()),
781        };
782        let a = h.cluster_or("pleme-dev");
783        let b = h.cluster_or("pleme-dev");
784        assert_eq!(a, b);
785        assert_eq!(a, "pleme-prod");
786    }
787
788    #[test]
789    fn cluster_or_borrow_form_return_shape_matches_fmt_fqdn_arg_shape() {
790        // The primitive returns `&str` so it slots straight into
791        // `fmt_fqdn(&hostname.app, eph_id, host_cluster, location,
792        // domain)` at `render::render_routing` without a
793        // `.to_string()` step. Compose here so a future return-shape
794        // change (owned `String`, `Cow<'_, str>`) breaks this pin,
795        // not the reconciler.
796        use crate::hostname::fmt_fqdn;
797        let h = RoutingHostname::instanced("api", "demo-prod");
798        let host_cluster: &str = h.cluster_or("pleme-dev");
799        let fqdn = fmt_fqdn(
800            &h.app,
801            h.instance.as_deref().unwrap(),
802            host_cluster,
803            "use1",
804            "quero.lol",
805        )
806        .expect("fmt_fqdn");
807        assert_eq!(fqdn, "api.demo-prod.pleme-dev.use1.quero.lol");
808    }
809
810    #[test]
811    fn cluster_or_matches_pre_lift_chain_verbatim() {
812        // Full 4-corner byte-identical parity table across the
813        // `(cluster slot × fallback shape)` axis pair. Any
814        // divergence between the primitive and each pre-lift
815        // callsite's inline chain surfaces HERE rather than as
816        // per-site operator-visible drift.
817        let fallbacks = ["pleme-dev", "pleme-prod", "", "some-other-cluster"];
818        let cluster_slots = [
819            None,
820            Some(String::new()),
821            Some("pleme-prod".into()),
822            Some("edge-1".into()),
823        ];
824        for fallback in fallbacks {
825            for cluster in &cluster_slots {
826                let h = RoutingHostname {
827                    app: "api".into(),
828                    instance: None,
829                    cluster: cluster.clone(),
830                };
831                let pre_lift = h.cluster.as_deref().unwrap_or(fallback);
832                let via_primitive = h.cluster_or(fallback);
833                assert_eq!(
834                    via_primitive, pre_lift,
835                    "primitive must match pre-lift `.as_deref().unwrap_or(fallback)` chain \
836                     byte-identically at (fallback={fallback:?}, cluster={cluster:?})"
837                );
838            }
839        }
840    }
841
842    #[test]
843    fn cluster_or_composes_with_stable_group_key_shape() {
844        // Peer-composition pin against
845        // `table_controller::stable_name_group_key`'s downstream
846        // seed shape (`format!("{cluster}/{}", hostname.app)`).
847        // A future rename of the separator or the composer's
848        // ordering breaks this pin, not the claim-arbiter row seed.
849        let h = RoutingHostname::content_hashed("api");
850        let cluster = h.cluster_or("pleme-dev");
851        let key = format!("{cluster}/{}", h.app);
852        assert_eq!(key, "pleme-dev/api");
853
854        let h_over = RoutingHostname {
855            app: "api".into(),
856            instance: None,
857            cluster: Some("pleme-prod".into()),
858        };
859        let cluster = h_over.cluster_or("pleme-dev");
860        let key = format!("{cluster}/{}", h_over.app);
861        assert_eq!(key, "pleme-prod/api");
862    }
863
864    #[test]
865    fn cluster_or_lifetime_ties_output_to_the_shorter_of_self_or_fallback() {
866        // Compile-time proof (via the return signature) that the
867        // returned slice borrows through EITHER `&self.cluster` or
868        // `&fallback` — the caller cannot outlive the shorter of
869        // the two. If a future refactor loosens the lifetime to
870        // `&'a str` where `'a` is only tied to `self`, this test
871        // stops compiling with the fallback-borrow arm.
872        let h = RoutingHostname::content_hashed("api");
873        {
874            let fallback = String::from("pleme-dev");
875            let slice = h.cluster_or(&fallback);
876            assert_eq!(slice, "pleme-dev");
877            // `slice` cannot escape this scope — its lifetime is
878            // bounded by `fallback`. That's the compile-time
879            // discipline the `<'a>` on the primitive encodes.
880        }
881    }
882
883    // ─── RoutingHostname::instanced substrate pins ───────────────
884    //
885    // The pre-lift workspace restated the 3-slot `RoutingHostname {
886    // app, instance: Some(<i>), cluster: None }` fixture literal at
887    // TEN hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
888    // duplication trigger. Every corner of the shipped shape is
889    // pinned here so a regression that drifted the default-cluster
890    // sentinel from `None` to a hardcoded string, or reordered the
891    // three struct slots, surfaces at THIS composer's shipped-shape
892    // pin rather than as silent skew at every downstream fixture.
893
894    #[test]
895    fn instanced_composes_populated_instance_with_default_cluster() {
896        let h = RoutingHostname::instanced("api", "demo-prod");
897        assert_eq!(h.app, "api");
898        assert_eq!(h.instance.as_deref(), Some("demo-prod"));
899        assert!(h.cluster.is_none());
900    }
901
902    #[test]
903    fn instanced_composes_byte_identical_to_pre_lift_literal_across_every_app_instance_pair() {
904        // Full 3-corner byte-identical parity table across the
905        // `(app × instance)` axis pair. Any divergence between the
906        // primitive and each pre-lift callsite's inline literal
907        // surfaces HERE rather than as per-site operator-visible
908        // drift.
909        let pairs = [
910            ("api", "demo-prod"),
911            ("gateway", "demo-prod"),
912            ("x", "env-a"),
913        ];
914        for (app, instance) in pairs {
915            let via_primitive = RoutingHostname::instanced(app, instance);
916            let pre_lift = RoutingHostname {
917                app: app.into(),
918                instance: Some(instance.into()),
919                cluster: None,
920            };
921            assert_eq!(
922                via_primitive, pre_lift,
923                "primitive must match pre-lift `RoutingHostname {{ app, instance: Some(..), \
924                 cluster: None }}` literal byte-identically at (app={app:?}, instance={instance:?})"
925            );
926        }
927    }
928
929    #[test]
930    fn instanced_is_named_via_peer_projection() {
931        // Peer-composition pin: the primitive's shipped shape must
932        // continue to satisfy `is_named` (the sibling `RoutingHostname`
933        // projection that reads the same `instance` slot).
934        assert!(RoutingHostname::instanced("api", "demo-prod").is_named());
935    }
936
937    #[test]
938    fn instanced_cluster_or_falls_back_to_caller_string() {
939        // Peer-composition pin against `cluster_or`: the primitive
940        // stamps `cluster: None`, so `cluster_or` MUST return the
941        // caller-supplied fallback verbatim.
942        let h = RoutingHostname::instanced("api", "demo-prod");
943        assert_eq!(h.cluster_or("pleme-dev"), "pleme-dev");
944    }
945
946    // ─── RoutingHostname::content_hashed substrate pins ──────────
947    //
948    // The pre-lift workspace restated the 3-slot `RoutingHostname {
949    // app, instance: None, cluster: None }` fixture literal at NINE
950    // hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
951    // trigger. Every corner of the shipped shape is pinned here.
952
953    #[test]
954    fn content_hashed_composes_unset_instance_with_default_cluster() {
955        let h = RoutingHostname::content_hashed("smoke");
956        assert_eq!(h.app, "smoke");
957        assert!(h.instance.is_none());
958        assert!(h.cluster.is_none());
959    }
960
961    #[test]
962    fn content_hashed_composes_byte_identical_to_pre_lift_literal_across_every_app_slot() {
963        let apps = ["api", "gateway", "smoke", "x"];
964        for app in apps {
965            let via_primitive = RoutingHostname::content_hashed(app);
966            let pre_lift = RoutingHostname {
967                app: app.into(),
968                instance: None,
969                cluster: None,
970            };
971            assert_eq!(
972                via_primitive, pre_lift,
973                "primitive must match pre-lift `RoutingHostname {{ app, instance: None, \
974                 cluster: None }}` literal byte-identically at (app={app:?})"
975            );
976        }
977    }
978
979    #[test]
980    fn content_hashed_is_not_named() {
981        // Peer-composition pin against `is_named`: an unset
982        // `instance` slot is definitionally content-hashed, i.e. NOT
983        // named — the reconciler's FQDN composer downstream
984        // substitutes `blake3(canonical_spec)[:8]` for the segment.
985        assert!(!RoutingHostname::content_hashed("smoke").is_named());
986    }
987
988    // ─── RoutingBackend::plain substrate pins ────────────────────
989    //
990    // The pre-lift workspace restated the 4-slot `RoutingBackend {
991    // service, port, tls_issuer: None, ingress_annotations:
992    // BTreeMap::new() }` fixture literal at SEVEN hand-authored sites
993    // past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger.
994
995    #[test]
996    fn plain_composes_default_issuer_and_empty_annotations() {
997        let b = RoutingBackend::plain("svc", 8080);
998        assert_eq!(b.service, "svc");
999        assert_eq!(b.port, 8080);
1000        assert!(b.tls_issuer.is_none());
1001        assert!(b.ingress_annotations.is_empty());
1002    }
1003
1004    #[test]
1005    fn plain_composes_byte_identical_to_pre_lift_literal_across_every_service_port_pair() {
1006        let pairs = [
1007            ("demo-app-gateway", 8000_u16),
1008            ("svc", 80),
1009            ("svc", 8080),
1010            ("x", 80),
1011        ];
1012        for (service, port) in pairs {
1013            let via_primitive = RoutingBackend::plain(service, port);
1014            let pre_lift = RoutingBackend {
1015                service: service.into(),
1016                port,
1017                tls_issuer: None,
1018                ingress_annotations: BTreeMap::new(),
1019            };
1020            assert_eq!(
1021                via_primitive, pre_lift,
1022                "primitive must match pre-lift `RoutingBackend {{ service, port, tls_issuer: \
1023                 None, ingress_annotations: BTreeMap::new() }}` literal byte-identically at \
1024                 (service={service:?}, port={port})"
1025            );
1026        }
1027    }
1028
1029    #[test]
1030    fn plain_wire_form_skips_defaulted_slots() {
1031        // Peer-composition pin: the primitive stamps `tls_issuer:
1032        // None` + empty `ingress_annotations`, both of which are
1033        // `serde(skip_serializing_if)` — so the wire form MUST NOT
1034        // include either key. A regression that flipped the default
1035        // sentinels to non-empty values would leak them into every
1036        // rendered wire form; this pin fails first.
1037        let b = RoutingBackend::plain("svc", 80);
1038        let yaml = serde_yaml::to_string(&b).unwrap();
1039        assert!(!yaml.contains("tlsIssuer:"));
1040        assert!(!yaml.contains("ingressAnnotations:"));
1041        assert!(yaml.contains("service: svc"));
1042        assert!(yaml.contains("port: 80"));
1043    }
1044
1045    #[test]
1046    fn serde_round_trip_via_yaml() {
1047        let r = demo_routing();
1048        let yaml = serde_yaml::to_string(&r).unwrap();
1049        // camelCase wire form — what FluxCD / kubectl users see.
1050        assert!(yaml.contains("hostnames:"));
1051        assert!(yaml.contains("app: api"));
1052        assert!(yaml.contains("instance: demo-prod"));
1053        assert!(yaml.contains("backend:"));
1054        assert!(yaml.contains("service: demo-app-gateway"));
1055        assert!(yaml.contains("port: 8000"));
1056        assert!(yaml.contains("stableNameClaim: true"));
1057        assert!(yaml.contains("priority: 100"));
1058
1059        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
1060        assert_eq!(back.hostnames.len(), 2);
1061        assert!(back.stable_name_claim);
1062        assert_eq!(back.priority, 100);
1063    }
1064
1065    #[test]
1066    fn empty_fields_skip_serialize() {
1067        // Minimal RoutingSpec — verify that absent optional fields
1068        // don't pollute the wire format.
1069        let r = RoutingSpec {
1070            hostnames: vec![RoutingHostname::content_hashed("api")],
1071            backend: RoutingBackend::plain("svc", 8080),
1072            stable_name_claim: false,
1073            priority: 0,
1074        };
1075        let yaml = serde_yaml::to_string(&r).unwrap();
1076        // Optional + empty fields must NOT appear in the wire form.
1077        assert!(!yaml.contains("instance:"));
1078        assert!(!yaml.contains("cluster:"));
1079        assert!(!yaml.contains("tlsIssuer:"));
1080        assert!(!yaml.contains("ingressAnnotations:"));
1081    }
1082
1083    #[test]
1084    fn lisp_round_trip_via_defrouting() {
1085        // The `(defrouting …)` keyword is registered by
1086        // tatara_process::register_all (R3 adds this to the
1087        // registry); for now compile via tatara_lisp directly.
1088        let src = r#"
1089            (defrouting demo-edges
1090              :hostnames ((:app "api"   :instance "demo-prod")
1091                          (:app "gateway" :instance "demo-prod"))
1092              :backend   (:service "demo-app-gateway"
1093                          :port 8000)
1094              :stable-name-claim #t
1095              :priority 100)
1096        "#;
1097        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
1098            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
1099        assert_eq!(defs.len(), 1);
1100        let d = &defs[0];
1101        assert_eq!(d.name, "demo-edges");
1102        assert_eq!(d.spec.hostnames.len(), 2);
1103        assert_eq!(d.spec.hostnames[0].app, "api");
1104        assert_eq!(d.spec.hostnames[0].instance.as_deref(), Some("demo-prod"));
1105        assert_eq!(d.spec.backend.service, "demo-app-gateway");
1106        assert_eq!(d.spec.backend.port, 8000);
1107        assert!(d.spec.stable_name_claim);
1108        assert_eq!(d.spec.priority, 100);
1109    }
1110
1111    #[test]
1112    fn lisp_round_trip_anonymous_instance() {
1113        // `:instance` omitted ⇒ content-hash form (filled in by the
1114        // hostname helper, not stored). Round-trip via Lisp +
1115        // serde proves the Option<String> default flows cleanly.
1116        let src = r#"
1117            (defrouting smoke-edges
1118              :hostnames ((:app "smoke"))
1119              :backend   (:service "smoke" :port 80))
1120        "#;
1121        let defs: Vec<tatara_lisp::NamedDefinition<RoutingSpec>> =
1122            tatara_lisp::compile_named::<RoutingSpec>(src).expect("compile");
1123        let d = &defs[0];
1124        assert_eq!(d.spec.hostnames.len(), 1);
1125        assert_eq!(d.spec.hostnames[0].instance, None);
1126        assert!(!d.spec.stable_name_claim); // default false
1127        assert_eq!(d.spec.priority, 0); // default 0
1128    }
1129
1130    // ─── RoutingForm substrate pins ───────────────────────────────
1131    //
1132    // The pre-lift `tatara-reconciler::edges` sites hand-wrote
1133    // three `if ctx.is_stable { "stable" } else { "instance" }`
1134    // ternaries at every axis (Ingress annotation, Ingress label,
1135    // DNSEndpoint label) plus two byte-literal reads in render
1136    // tests. Every byte the ternary + literals produced is pinned
1137    // here so a rename of a `RoutingForm::as_str` arm surfaces at
1138    // THIS composer's shipped-shape pin rather than as silent
1139    // drift between the pre-lift edge sites (which pre-lift had
1140    // already grown five copies of the same two-literal set).
1141
1142    #[test]
1143    fn routing_form_as_str_matches_wire_form_pre_lift() {
1144        // Byte-identity pin: the pre-lift ternary at
1145        // `edges.rs::IngressEdge::render`,
1146        // `edges.rs::DnsEndpointEdge::render` restated these two
1147        // literals verbatim. A rename here is an
1148        // operator-visible selector-mismatch after apply.
1149        assert_eq!(RoutingForm::Stable.as_str(), "stable");
1150        assert_eq!(RoutingForm::Instance.as_str(), "instance");
1151    }
1152
1153    #[test]
1154    fn routing_form_from_is_stable_routes_true_and_false() {
1155        // Boolean → enum decision pinned here rather than restated
1156        // as an inline ternary at every callsite.
1157        assert_eq!(RoutingForm::from_is_stable(true), RoutingForm::Stable);
1158        assert_eq!(RoutingForm::from_is_stable(false), RoutingForm::Instance);
1159    }
1160
1161    #[test]
1162    fn routing_form_round_trip_via_bool() {
1163        // The decision the reconciler's `EdgeContext::is_stable`
1164        // bool encodes is a two-variant disjunction; round-trip
1165        // both bool values through the enum to prove the composer
1166        // preserves the axis in both directions.
1167        for is_stable in [true, false] {
1168            let form = RoutingForm::from_is_stable(is_stable);
1169            let expected = if is_stable { "stable" } else { "instance" };
1170            assert_eq!(form.as_str(), expected);
1171        }
1172    }
1173
1174    // ─── RoutingForm closed-set algebra (ALL × as_str × FromStr ×
1175    //    Display) ────────────────────────────────────────────────
1176    //
1177    // The `#[derive(DeriveClosedSet)]` line auto-emits Display,
1178    // FromStr, and the `tatara_closed_set::ClosedSet` trait impl.
1179    // Pin the workspace-wide well-formedness triad here so a
1180    // regression that (a) drifted a variant's `as_str` label, (b)
1181    // dropped a variant from `ALL`, or (c) allowed the empty
1182    // string to parse would fail HERE at ONE narrow site.
1183
1184    /// Structural well-formedness of [`RoutingForm`] as a
1185    /// [`tatara_closed_set::ClosedSet`] implementor — the workspace-wide
1186    /// testkit lift that pins all three structural invariants (`ALL`
1187    /// is non-empty, every variant round-trips through `label ↔
1188    /// parse_label`, labels are pairwise distinct, `""` is outside
1189    /// the closed set) at ONE call site. `FromStr` delegates to
1190    /// `<Self as tatara_closed_set::ClosedSet>::parse_label`, so this
1191    /// helper exercises the same code path the require-tag classifier
1192    /// hits when parsing a `routing-form-<kind>` suffix.
1193    #[test]
1194    fn routing_form_is_well_formed_closed_set() {
1195        tatara_closed_set::assert_closed_set_well_formed::<RoutingForm>();
1196    }
1197
1198    /// The Display impl IS `as_str` — pinning this lets future
1199    /// callers (notably the require-tag classifier's error path)
1200    /// reach for either projection without drift.
1201    #[test]
1202    fn routing_form_display_matches_as_str() {
1203        crate::tagged_union::assert_display_matches_label::<RoutingForm>();
1204    }
1205
1206    /// `FromStr` rejects strings that aren't in the canonical
1207    /// projection — capitalized (`Stable` / `Instance` do not
1208    /// match the lowercase `as_str` labels), typo, unrelated — and
1209    /// the error echoes the input verbatim so the operator-facing
1210    /// diagnostic carries the offending value, not a normalized
1211    /// form. The empty-input arm is pinned by
1212    /// [`routing_form_is_well_formed_closed_set`] via the
1213    /// `tatara_closed_set::ClosedSet` testkit; the cases here pin the
1214    /// verbatim-echo contract on the [`UnknownRoutingForm`] newtype,
1215    /// which the trait's `make_unknown` can't see.
1216    #[test]
1217    fn unknown_routing_form_errors() {
1218        use std::str::FromStr;
1219        for bad in ["Stable", "Instance", "STABLE", "instances", "Gateway"] {
1220            let err = RoutingForm::from_str(bad).unwrap_err();
1221            assert_eq!(err.0, bad, "error payload should echo input verbatim");
1222        }
1223    }
1224
1225    /// `ALL` and the [`Self::from_is_stable`] composer agree on the
1226    /// two-variant partition — walking every `RoutingForm` variant
1227    /// finds a bool that composes back to it via `from_is_stable`,
1228    /// and walking every bool composes to a variant in `ALL`. Locks
1229    /// the (bool × RoutingForm) round-trip so a regression that
1230    /// dropped a variant from `ALL` or drifted the `from_is_stable`
1231    /// mapping fails HERE.
1232    #[test]
1233    fn routing_form_all_partitions_both_stable_name_claim_bool_arms() {
1234        assert_eq!(RoutingForm::ALL.len(), 2);
1235        for is_stable in [true, false] {
1236            let form = RoutingForm::from_is_stable(is_stable);
1237            assert!(
1238                RoutingForm::ALL.contains(&form),
1239                "from_is_stable({is_stable}) => {form:?} must be in RoutingForm::ALL",
1240            );
1241        }
1242        assert_eq!(RoutingForm::from_is_stable(true), RoutingForm::Stable);
1243        assert_eq!(RoutingForm::from_is_stable(false), RoutingForm::Instance);
1244    }
1245
1246    // ─── RoutingSpec::form + has_form substrate pins ─────────────
1247    //
1248    // Fail-before-pass-after granularity: [`RoutingSpec::form`] and
1249    // [`RoutingSpec::has_form`] did not exist before this commit —
1250    // every consumer of the `(RoutingSpec, RoutingForm) -> bool`
1251    // scalar-carrier probe shape restated the
1252    // `RoutingForm::from_is_stable(r.stable_name_claim) == kind`
1253    // closure body at its own callsite (or, equivalently, the raw
1254    // `r.stable_name_claim` bool + the `if is_stable { … } else { … }`
1255    // ternary that pre-dates [`RoutingForm::from_is_stable`]).
1256    // Post-lift the shape lives at ONE substrate owner and every
1257    // downstream (the `routing-form-<kind>` require-tag family in
1258    // `tatara-check`, future audit dispatchers walking
1259    // [`RoutingForm::ALL`], any future CRD-facing closed-set
1260    // discriminator derived from `spec.routing`) binds through the
1261    // SAME `has(kind)` shape the Option-slot (Intent::has,
1262    // Lifetime::has), slice-level (ExportSpecSliceExt::has_when +
1263    // peers), required-parent scalar-carrier
1264    // (SignalPolicy::has_sighup_strategy), and Option-parent
1265    // defaulted-scalar-child stored-carrier
1266    // (EphemeralLifetime::has_teardown_policy) primitives publish.
1267
1268    #[test]
1269    fn form_projects_stable_when_stable_name_claim_is_true() {
1270        let r = RoutingSpec {
1271            hostnames: vec![RoutingHostname::content_hashed("api")],
1272            backend: RoutingBackend::plain("svc", 80),
1273            stable_name_claim: true,
1274            priority: 0,
1275        };
1276        assert_eq!(r.form(), RoutingForm::Stable);
1277    }
1278
1279    #[test]
1280    fn form_projects_instance_when_stable_name_claim_is_false() {
1281        let r = RoutingSpec {
1282            hostnames: vec![RoutingHostname::content_hashed("api")],
1283            backend: RoutingBackend::plain("svc", 80),
1284            stable_name_claim: false,
1285            priority: 0,
1286        };
1287        assert_eq!(r.form(), RoutingForm::Instance);
1288    }
1289
1290    #[test]
1291    fn form_composes_through_the_one_from_is_stable_projection() {
1292        // The projection MUST delegate to
1293        // [`RoutingForm::from_is_stable`] byte-identically — a
1294        // regression that hard-coded the mapping at
1295        // `RoutingSpec::form` (inverting the ternary, defaulting to
1296        // Stable, etc.) would drift from every other consumer of
1297        // `from_is_stable`. Sweep both bool arms and pin the
1298        // through-projection at ONE narrow site.
1299        for is_stable in [true, false] {
1300            let r = RoutingSpec {
1301                hostnames: vec![RoutingHostname::content_hashed("api")],
1302                backend: RoutingBackend::plain("svc", 80),
1303                stable_name_claim: is_stable,
1304                priority: 0,
1305            };
1306            assert_eq!(
1307                r.form(),
1308                RoutingForm::from_is_stable(is_stable),
1309                "form() must delegate to from_is_stable({is_stable})",
1310            );
1311        }
1312    }
1313
1314    #[test]
1315    fn has_form_returns_true_on_diagonal_and_false_off_diagonal_across_all_kinds() {
1316        // DIAGONAL + OFF-DIAGONAL pin — sweep the (populated bool,
1317        // query kind) cross and verify variant-equality on the
1318        // diagonal, non-equality off it. Locks the scalar-comparison
1319        // semantics so a regression that (a) hard-coded the arm to
1320        // `true` (silently confirming every kind on every routing
1321        // spec), (b) inverted the comparison, or (c) wired the
1322        // closure to an unrelated field (a stray probe on `priority`
1323        // / `hostnames.len()`) fails HERE.
1324        for is_stable in [true, false] {
1325            let r = RoutingSpec {
1326                hostnames: vec![RoutingHostname::content_hashed("api")],
1327                backend: RoutingBackend::plain("svc", 80),
1328                stable_name_claim: is_stable,
1329                priority: 0,
1330            };
1331            let populated = RoutingForm::from_is_stable(is_stable);
1332            for query in RoutingForm::ALL {
1333                let expected = query == populated;
1334                assert_eq!(
1335                    r.has_form(query),
1336                    expected,
1337                    "stable_name_claim={is_stable} populated={populated:?}: has_form({query:?}) drift",
1338                );
1339            }
1340        }
1341    }
1342
1343    #[test]
1344    fn has_form_default_arm_is_instance() {
1345        // DEFAULT-ARM SHORT-CIRCUIT pin — a RoutingSpec whose
1346        // `stable_name_claim` slot is at its `#[serde(default)]`
1347        // (bool default = `false`) answers `true` on
1348        // `RoutingForm::Instance` and `false` on every other variant
1349        // WITHOUT the operator naming the axis. The
1350        // `#[serde(default)]` on `stable_name_claim` composes
1351        // through the ONE `from_is_stable(false) = Instance`
1352        // projection at THIS scalar-carrier probe. Locks the
1353        // (Option-parent-adjacent × defaulted-scalar-child) corner's
1354        // default-arm short-circuit shape at ONE narrow site so a
1355        // regression that (a) drifted the bool default to `true`
1356        // (silently promoting every unadorned routing spec to
1357        // Stable), (b) drifted the `from_is_stable(false)` arm to
1358        // `Stable` (inverting the closed-set default), or (c) wired
1359        // the has_form arm to a fixed answer would fail HERE.
1360        let r = RoutingSpec {
1361            hostnames: vec![RoutingHostname::content_hashed("api")],
1362            backend: RoutingBackend::plain("svc", 80),
1363            stable_name_claim: bool::default(),
1364            priority: 0,
1365        };
1366        for kind in RoutingForm::ALL {
1367            let expected = kind == RoutingForm::Instance;
1368            assert_eq!(
1369                r.has_form(kind),
1370                expected,
1371                "default (stable_name_claim=false) baseline: has_form({kind:?}) must be {expected}",
1372            );
1373        }
1374    }
1375
1376    #[test]
1377    fn has_form_coexists_with_has_hostnames() {
1378        // COEXISTENCE pin — the routing-form axis is orthogonal to
1379        // the hostname-presence axis: `has_form(<kind>)` is a
1380        // spec-shape probe on the derived `RoutingForm`; the
1381        // (independent) `has_hostnames` probe walks the
1382        // `Vec<RoutingHostname>` slice. Locks the two axes at ONE
1383        // site so a regression that crossed the wires (probing
1384        // `hostnames.is_empty()` for a routing-form query, or
1385        // vice-versa) fails HERE.
1386        for is_stable in [true, false] {
1387            let r_with_hostnames = RoutingSpec {
1388                hostnames: vec![RoutingHostname::content_hashed("api")],
1389                backend: RoutingBackend::plain("svc", 80),
1390                stable_name_claim: is_stable,
1391                priority: 0,
1392            };
1393            let r_empty = RoutingSpec {
1394                hostnames: vec![],
1395                backend: RoutingBackend::plain("svc", 80),
1396                stable_name_claim: is_stable,
1397                priority: 0,
1398            };
1399            let form = RoutingForm::from_is_stable(is_stable);
1400            assert!(r_with_hostnames.has_hostnames());
1401            assert!(!r_empty.has_hostnames());
1402            assert!(r_with_hostnames.has_form(form));
1403            assert!(r_empty.has_form(form));
1404        }
1405    }
1406
1407    // ── RoutingSpecOptionExt — Option-carrier collapse contract ──
1408
1409    /// COLLAPSED-NONE CONTRACT: a `None` outer Option carries no
1410    /// routing surface, so [`RoutingSpecOptionExt::has_form`] returns
1411    /// `false` for every [`RoutingForm`] — INCLUDING the derived
1412    /// default [`RoutingForm::Instance`] that a POPULATED
1413    /// [`RoutingSpec`] with `stable_name_claim` at its
1414    /// `#[serde(default)] = false` would publish. An operator who
1415    /// declined the routing surface entirely is NOT configured for
1416    /// `Instance`; the derived-scalar default only fires when the
1417    /// parent Option is `Some(_)` and the discriminating slot is at
1418    /// its default. Pre-lift the require-tag classifier restated
1419    /// `spec.routing.as_ref().is_some_and(|r| r.has_form(k))`
1420    /// inline; post-lift the collapse is a peer to
1421    /// [`crate::encapsulates::EncapsulatesSpecOptionExt`] on the
1422    /// sibling Option-parent axis of [`crate::crd::ProcessSpec`].
1423    #[test]
1424    fn routing_spec_option_ext_has_form_returns_false_on_none_for_every_kind() {
1425        let opt: Option<RoutingSpec> = None;
1426        for kind in RoutingForm::ALL {
1427            assert!(
1428                !opt.has_form(kind),
1429                "None carrier reported has_form({kind:?}) = true; \
1430                 the Option-carrier collapse arm must return false \
1431                 for every RoutingForm kind, including the derived \
1432                 default RoutingForm::Instance (an in-cluster-only \
1433                 Process declined the routing surface — it did NOT \
1434                 opt into Instance by omission)"
1435            );
1436        }
1437    }
1438
1439    /// DELEGATED-SOME CONTRACT: a `Some(r)` outer Option forwards
1440    /// to [`RoutingSpec::has_form`] byte-identically across the full
1441    /// (populated `stable_name_claim` bool × query kind) cross. Pins
1442    /// that the extension trait's projection on the populated arm
1443    /// equals the inner-spec probe's answer for every combination —
1444    /// so the collapse-arm's `false` on `None` is the ONLY
1445    /// behavioral change introduced by the lift. A regression that
1446    /// (a) inverted the delegation, (b) dropped the closure, or
1447    /// (c) wired the arm to a fixed answer would fail HERE.
1448    #[test]
1449    fn routing_spec_option_ext_has_form_matches_inner_probe_when_present() {
1450        for is_stable in [true, false] {
1451            let inner = RoutingSpec {
1452                hostnames: vec![RoutingHostname::content_hashed("api")],
1453                backend: RoutingBackend::plain("svc", 80),
1454                stable_name_claim: is_stable,
1455                priority: 0,
1456            };
1457            let opt: Option<RoutingSpec> = Some(inner.clone());
1458            for probe in RoutingForm::ALL {
1459                assert_eq!(
1460                    opt.has_form(probe),
1461                    inner.has_form(probe),
1462                    "Some-arm projection diverged from inner probe: \
1463                     is_stable={is_stable} probe={probe:?}"
1464                );
1465            }
1466        }
1467    }
1468
1469    #[test]
1470    fn routing_form_annotation_key_is_prefixed_process_ns() {
1471        // Byte-shape pin against the pre-lift string literal
1472        // `edges.rs` restated four times (two annotation branches
1473        // + two label sites). A rename that missed one of the
1474        // pre-lift sites would silently split the axis across two
1475        // K8s label keys — the const now closes that drift path.
1476        assert_eq!(
1477            crate::annotations::ROUTING_FORM,
1478            "tatara.pleme.io/routing-form"
1479        );
1480    }
1481
1482    #[test]
1483    fn routing_app_annotation_key_is_prefixed_process_ns() {
1484        // Peer to `ROUTING_FORM`: pre-lift restated at the two
1485        // `edges.rs` label sites (Ingress + DNSEndpoint).
1486        assert_eq!(crate::annotations::APP, "tatara.pleme.io/app");
1487    }
1488
1489    #[test]
1490    fn ingress_annotations_round_trip() {
1491        let mut annotations = BTreeMap::new();
1492        annotations.insert(
1493            "nginx.ingress.kubernetes.io/rate-limit".into(),
1494            "100".into(),
1495        );
1496        annotations.insert(
1497            "nginx.ingress.kubernetes.io/proxy-body-size".into(),
1498            "10m".into(),
1499        );
1500        let r = RoutingSpec {
1501            hostnames: vec![RoutingHostname::content_hashed("api")],
1502            backend: RoutingBackend {
1503                service: "svc".into(),
1504                port: 8080,
1505                tls_issuer: Some("letsencrypt-prod".into()),
1506                ingress_annotations: annotations,
1507            },
1508            stable_name_claim: false,
1509            priority: 0,
1510        };
1511        let yaml = serde_yaml::to_string(&r).unwrap();
1512        assert!(yaml.contains("tlsIssuer: letsencrypt-prod"));
1513        assert!(yaml.contains("rate-limit"));
1514        let back: RoutingSpec = serde_yaml::from_str(&yaml).unwrap();
1515        assert_eq!(back.backend.tls_issuer.as_deref(), Some("letsencrypt-prod"));
1516        assert_eq!(back.backend.ingress_annotations.len(), 2);
1517    }
1518}