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}