tatara_process/json_object.rs
1//! Substrate primitive over `serde_json::Value` — the ONE substrate
2//! owner of the `.as_object_mut().ok_or_else(|| anyhow::anyhow!(
3//! "<slot> is not an object"))` guard-shape every JSON-mutating helper
4//! restates by hand at the "walk this `Value` slot into its
5//! `serde_json::Map` interior or fail loud" boundary.
6//!
7//! Peer of the trait family that already lives in this crate on the
8//! wrap-shape axis:
9//!
10//! * [`crate::kube_error::KubeResultExt`] — the `kube::Error → anyhow`
11//! display-prefix wrap.
12//! * [`crate::hostname::HostnameResultExt`] — the `HostnameError →
13//! anyhow` display-prefix wrap.
14//! * [`crate::anyhow_flatten::FlattenCtxExt`] — the `anyhow::Error →
15//! anyhow` display-prefix flatten.
16//! * This module — the `Option<&mut Map> → anyhow::Result<&mut Map>`
17//! type-guard, partitioned from the three above by SOURCE (`None`
18//! from the slot-typecheck, not a lifted error type) but sharing the
19//! `.map_err(|_| anyhow!("<slug>: …"))?` display-prefix wire format.
20//! The module also owns the READ-side [`ValueGetExt`] projector
21//! (`.get_i64(<key>) -> Option<i64>`) — sibling of the three
22//! MUTATION-side traits below on the (read, mutate) axis, closing
23//! the READ half of the `serde_json::Value` substrate the four
24//! traits jointly own.
25//!
26//! Pre-lift the shape was hand-authored at THREE adjacent private
27//! helpers in `tatara-reconciler::ssapply` past the ★★ PRIME-DIRECTIVE
28//! ≥ 2 duplication threshold:
29//!
30//! * `metadata_object_mut(resource)` — the root-guard step
31//! (`resource.as_object_mut().ok_or_else(|| anyhow!("resource is not
32//! an object"))?`) that opens the SSA-time
33//! `resource → &mut metadata` walk shared by `inject_owner_reference`
34//! + `inject_annotations`.
35//! * `metadata_object_mut(resource)` — the metadata-slot type-check
36//! step (`metadata.as_object_mut().ok_or_else(|| anyhow!("metadata
37//! is not an object"))?`) that closes the same walk — a resource
38//! whose author mistyped the `metadata` slot as an array / string
39//! surfaces as an error rather than as a silent
40//! `.as_object_mut() → None → skip` no-op.
41//! * `inject_annotations(resource, process)` — the annotations-slot
42//! type-check step (`annot.as_object_mut().ok_or_else(|| anyhow!(
43//! "annotations is not an object"))?`) that opens the SSA-time
44//! `metadata → &mut annotations` walk before the ownership tag +
45//! observed-* primitive family drops its keys into the map.
46//!
47//! All three restated the SAME 2-line shape verbatim: `.as_object_mut()`
48//! on a `serde_json::Value` handle already known to be non-null, then
49//! `.ok_or_else(|| anyhow!("<slot-name> is not an object"))` wrap
50//! whose slot name matched the walk step's semantic role (`"resource"`
51//! / `"metadata"` / `"annotations"`). THREE byte-for-byte identical
52//! guard blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
53//! differing only in the `&'static str` slot name each callsite
54//! stamped.
55//!
56//! Post-lift each callsite reads
57//! `<value>.as_object_mut_or("<slot>")?` and the guard-shape lives at
58//! ONE substrate owner here. The composed `anyhow::Error`'s `Display`
59//! is byte-identical to the pre-lift chain (`"<slot> is not an
60//! object"`), so operator-facing log output and any error-chain greps
61//! still match bytewise. A regression that drifts the message (a
62//! `"<slot> is not a JSON object"` synonym, a swapped `<slot>` slot,
63//! a promotion to a chain-form `source` that only surfaces via the
64//! alternate `{e:#}` formatter) surfaces at the tests below rather
65//! than as silent operator-facing drift across the three pre-lift
66//! consumers.
67//!
68//! ### Naming — `as_object_mut_or`, not `as_object_mut`
69//!
70//! Same discipline as the three sibling traits above — the trait
71//! method deliberately does NOT share a name with the inherent
72//! `serde_json::Value::as_object_mut` method (which returns
73//! `Option<&mut Map>`), because a name collision would let a caller
74//! who has `ValueObjectExt` in scope resolve to the inherent method
75//! by accident (inherent methods win over trait methods in method
76//! resolution) and silently drop the type-guard wrap altogether. The
77//! `_or` suffix names the intent: guard the `Option → Result` step
78//! at the same call, matching the pre-lift `.as_object_mut().
79//! ok_or_else(...)` chain.
80//!
81//! ### `#[must_use]`
82//!
83//! Every consumer threads the `?` short-circuit onto its handler's
84//! `Result<_, anyhow::Error>` return — dropping the guard swallows
85//! the underlying type-mismatch entirely, which is never the intended
86//! semantic at any of the three pre-lift consumers (each downstream
87//! `md.entry(...).or_insert_with(...)` / `annot.insert(...)` mutation
88//! depends on the returned `&mut Map` reference).
89//!
90//! Theory anchor: THEORY.md §VI.1 (generation over composition — the
91//! `.as_object_mut().ok_or_else(|| anyhow!("<slot> is not an
92//! object"))` guard-shape recurred at three hand-authored sites past
93//! the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
94//! ONE substrate owner here). THEORY.md §II.1 invariant 5 (composition
95//! preserves proofs — a regression that drifts the guard message
96//! wording at ONE site surfaces here at the substrate pin rather than
97//! as silent operator-facing skew across every SSA-time
98//! `metadata_object_mut` + `inject_annotations` mutation).
99
100use serde_json::{Map, Value};
101
102/// Substrate extension trait over `serde_json::Value` — the ONE
103/// substrate owner of the `.as_object_mut().ok_or_else(|| anyhow!(
104/// "<slot> is not an object"))` guard-shape. See the module docs for
105/// the full callsite audit + the naming rationale (why
106/// `as_object_mut_or` and not `as_object_mut`).
107pub trait ValueObjectExt {
108 /// Borrow the [`Value`] as a mutable JSON object [`Map`], or fail
109 /// loud with an [`anyhow::Error`] whose `Display` reads exactly
110 /// `"<slot> is not an object"` — the pre-lift wire format every
111 /// consumer's `tracing::error!(error = %e, ...)` log line already
112 /// encoded.
113 #[must_use = "an object-guard that isn't threaded via `?` swallows the underlying type mismatch"]
114 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>>;
115}
116
117impl ValueObjectExt for Value {
118 #[inline]
119 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>> {
120 self.as_object_mut()
121 .ok_or_else(|| anyhow::anyhow!("{slot} is not an object"))
122 }
123}
124
125/// Substrate extension trait over `serde_json::Map<String, Value>` —
126/// the ONE substrate owner of the `map.insert(<key>.into(),
127/// Value::String(<val>.into()))` string-slot insertion shape every
128/// JSON-mutating helper in the workspace hand-authored at each callsite.
129///
130/// Peer of [`ValueObjectExt`] above on the JSON-mutation axis, split
131/// by SHAPE: [`ValueObjectExt::as_object_mut_or`] owns the "walk into
132/// this `Value`'s object-shape interior or fail loud" guard;
133/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
134/// a string-typed key" write shape that every consumer downstream of
135/// the guard uses to populate the returned `&mut Map`.
136///
137/// Pre-lift the shape was hand-authored at SEVENTEEN production emit
138/// sites across `tatara-reconciler` past the ★★ PRIME-DIRECTIVE ≥ 2
139/// duplication threshold:
140///
141/// * `ssapply::inject_annotations` × 4 — the SSA-time observed-*
142/// annotation stamp family (`PID`, `CONTENT_HASH`, `GENERATION`,
143/// `ATTESTATION_ROOT`) each restated the 2-line `annot.insert(
144/// <annotation-const>.to_string(), Value::String(<val>.<coerce>))`
145/// shape verbatim.
146/// * `render::render_flux` × 3 — the Flux `Kustomization.spec` seeds
147/// (`interval`, `path`, `targetNamespace`), each restating the same
148/// `spec.insert("<key>".into(), Value::String(<val>))` shape.
149/// * `render::render_aplicacao` × 3 — the Flux `HelmRelease.spec`
150/// seeds (`releaseName`, `targetNamespace`) plus the values-overlay
151/// `profile` slot, each restating the same insert shape.
152/// * `render::render_export_job` × 2 — the export-Job outer label map
153/// (`ROLE`, `EXPORT_INDEX`) each restating the same insert shape.
154/// * `edges::IngressEdge::render` × 1 — the cert-manager
155/// `cluster-issuer` annotation, restating the same insert shape.
156/// * `edges::routing_edge_labels` × 2 — the routing-edge `metadata.
157/// labels` map's non-ownership slots (`APP`, `ROUTING_FORM`), each
158/// restating the same `labels.insert(<annotation-const>.to_string(),
159/// Value::String(<val>.into()))` shape past the ★★ PRIME-DIRECTIVE
160/// ≥ 2 duplication threshold — one of TWO adjacent bypass sites the
161/// substrate owner reaches through this lift.
162/// * `render::mark_resources_as_adopting` × 2 — the `encapsulation-
163/// mode` + `adopted-release` annotation stamps on adoption-mode
164/// render, each restating the same `anns_obj.insert("<key>".into(),
165/// Value::String(<val>.into()))` shape past the ★★ PRIME-DIRECTIVE
166/// ≥ 2 duplication threshold. The adopted-release value slot also
167/// routes its `<ns>/<release>` join through the sibling
168/// [`crate::qualified_process_ref`] substrate composer, closing a
169/// bare `format!("{ns}/{name}")` bypass on the `<ns>/<name>` join
170/// axis at this same callsite.
171///
172/// All SEVENTEEN pre-lift sites restated the SAME 2-line shape verbatim,
173/// differing only in the `&'static str` / `String` key + the `&str` /
174/// `String` value at each callsite. A copy-paste that dropped the
175/// `Value::String(...)` wrap (a caller who reached for
176/// `.insert(k, v)` after refactoring from a `Value` slot to a plain
177/// `String` value slot) would type-check silently at every callsite —
178/// `Map<String, Value>::insert` expects a `Value`, and `String:
179/// Into<Value>` is provided by `serde_json` via the `Value::String`
180/// arm's `From` impl, so the naive `.insert(k, v.to_string())` compiles
181/// AND writes the byte-identical JSON. Post-lift each callsite reads
182/// `<map>.insert_str(<key>, <val>)` and the string-slot write shape
183/// lives at ONE substrate owner here.
184///
185/// ### Composability
186///
187/// * Key slot accepts any `impl Into<String>`: `&str` (via
188/// `String::from`), `String` (identity), `Cow<'_, str>`, so a
189/// callsite with a static `annotations::PID` (`&'static str`) reads
190/// `insert_str(annotations::PID, …)` with no `.to_string()` per site.
191/// * Value slot accepts any `impl Into<String>`: `&str`, `String`,
192/// `Cow<'_, str>`. Numeric or non-string values still need an
193/// explicit `.to_string()` at the callsite — same as pre-lift, so
194/// the wrapping shape stays visible in the caller's grep footprint.
195/// * Returns `Option<Value>` matching the inherent
196/// `Map<String, Value>::insert` return semantics: `None` on new-key,
197/// `Some(prev)` on overwrite of an existing slot.
198///
199/// ### Naming — `insert_str`, not `insert`
200///
201/// Same discipline as [`ValueObjectExt::as_object_mut_or`] above — the
202/// trait method deliberately does NOT collide with the inherent
203/// `Map::insert` (which takes `(String, Value)` positionally). A name
204/// collision would let a caller who has `JsonMapStrExt` in scope
205/// resolve to the inherent method by accident (inherent methods win
206/// over trait methods in method resolution) and silently drop the
207/// `Value::String` wrap, stamping the value bytes straight into the
208/// map under a different `Value` variant. The `_str` suffix names the
209/// intent: the value slot IS the `Value::String` arm at this write.
210///
211/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
212/// `.insert(<k>.into(), Value::String(<v>.into()))` shape recurred at
213/// SEVENTEEN hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
214/// duplication trigger, and is lifted to ONE substrate owner here).
215/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
216/// regression that drifts the string-slot write shape at ONE consumer
217/// surfaces at the substrate pin rather than as silent per-emit skew
218/// across every ssapply / render / edges JSON emit site).
219pub trait JsonMapStrExt {
220 /// Insert a `Value::String(<val>.into())` at `<key>.into()` into
221 /// this JSON object map. Returns `Option<Value>` matching the
222 /// underlying `Map::insert` semantics — `None` for a new key,
223 /// `Some(prev)` for an overwrite.
224 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value>;
225}
226
227impl JsonMapStrExt for Map<String, Value> {
228 #[inline]
229 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value> {
230 self.insert(key.into(), Value::String(value.into()))
231 }
232}
233
234/// Substrate extension trait over `serde_json::Map<String, Value>` —
235/// the ONE substrate owner of the `.entry(<key>).or_insert_with(||
236/// Value::Object(<empty>))` seed-then-guard shape every JSON-mutating
237/// helper hand-authored at the "walk into this object slot on the
238/// parent map, seeding an empty object if the slot is absent, or fail
239/// loud if the slot exists but is a non-object" boundary.
240///
241/// Peer of [`ValueObjectExt::as_object_mut_or`] and
242/// [`JsonMapStrExt::insert_str`] on the JSON-mutation axis; split by
243/// SHAPE + SITE. [`ValueObjectExt::as_object_mut_or`] owns the "guard
244/// a `Value` handle into its object interior" step at ONE level;
245/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
246/// a string-typed key" write shape; this trait owns the compound
247/// "get-or-seed the object at a slot, then guard" step every SSA-time
248/// re-injection walks when the caller intends to reach a nested
249/// object slot without asserting whether the parent has already
250/// populated it (a caller composing a fresh resource-body carries
251/// no `metadata` / `metadata.annotations` slot pre-seed; a caller
252/// composing atop a pre-populated resource does — both paths reach
253/// the same primitive).
254///
255/// Pre-lift the compound shape was hand-authored at TWO adjacent
256/// private helpers in `tatara-reconciler::ssapply` past the ★★
257/// PRIME-DIRECTIVE ≥ 2 duplication threshold, both walking the SAME
258/// 3-step `let X = <map>.entry(<slot>).or_insert_with(|| Value::Object
259/// (<empty>)); X.as_object_mut_or(<slot>)?` incantation:
260///
261/// * `metadata_object_mut(resource)` — the `metadata` slot seed-then-
262/// guard step at the root of every SSA-time re-injection walk
263/// (`inject_owner_reference` + `inject_annotations` reach it).
264/// * `inject_annotations(resource, process)` — the `annotations`
265/// slot seed-then-guard step nested one level deeper under the
266/// `metadata` object the primitive above returned.
267///
268/// Both restated the SAME 3-line shape verbatim: `.entry(<slot>)` on
269/// a `Map<String, Value>` handle known to be an object, then
270/// `.or_insert_with(|| Value::Object(<empty>))` to synthesize an
271/// empty object at the slot when absent, then a `.as_object_mut_or
272/// (<slot>)?` guard on the returned `&mut Value` to fail loud when
273/// the existing slot is a non-object. TWO byte-for-byte identical
274/// blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
275/// differing only in the `&'static str` slot name each callsite
276/// stamped (`"metadata"` / `"annotations"`) — and the slot name is
277/// used at BOTH the entry key AND the guard error message so a
278/// regression that drifted the two apart at one callsite (a typo
279/// stamping `"metadata"` into the entry key + `"metadatas"` into
280/// the error message) would silently pass one pin and fail the
281/// other. Post-lift each callsite reads `<map>.object_slot_mut_or
282/// (<slot>)?` and the compound shape lives at ONE substrate owner
283/// here — the slot name is stamped ONCE per call and reaches both
284/// the entry key and the guard error slot mechanically.
285///
286/// ### Composability
287///
288/// * Slot name is `&'static str` — pre-lift both callsites stamped
289/// `&'static str` literals (`"metadata"` / `"annotations"`); a
290/// dynamic-slot caller (a callsite that reached this primitive
291/// with a `String` key computed at runtime) has no pre-lift
292/// precedent in the ssapply/render axis, so the `&'static str`
293/// bound stays honest to the pre-lift shape. A future caller
294/// needing a runtime slot name can widen this to
295/// `impl Into<String>` at the substrate; the pre-lift consumers
296/// inherit it mechanically.
297/// * Returns `anyhow::Result<&mut Map<String, Value>>` — matches the
298/// sibling [`ValueObjectExt::as_object_mut_or`] shape so the
299/// downstream `.entry(...).or_insert_with(...)` / `.insert(...)`
300/// mutation threads through `?` onto the caller's
301/// `Result<_, anyhow::Error>` return exactly as pre-lift.
302/// * Ok-arm returns the SAME `&mut Map<String, Value>` the pre-lift
303/// `.as_object_mut_or(<slot>)` step returned — no clone, no key-
304/// order reshape, no synthesis.
305///
306/// ### Naming — `object_slot_mut_or`, not `entry_object` or
307/// `get_or_insert_object_mut`
308///
309/// Same discipline as the two sibling traits above — the trait method
310/// deliberately does NOT collide with the inherent `Map::entry` /
311/// `Map::get_mut` / `Map::insert` methods (any of which a caller who
312/// has this trait in scope could resolve to by accident, silently
313/// dropping the type-guard step). The `_or` suffix names the intent
314/// (guard the `Option → Result` step at the same call, matching the
315/// pre-lift `.as_object_mut_or(<slot>)?` guard); `object_slot_mut`
316/// names the target shape (return an `&mut` object-typed `Map` at
317/// the slot). Together they read as "guard the slot into a mutable
318/// object interior or fail loud", matching the pre-lift semantics
319/// exactly.
320///
321/// ### `#[must_use]`
322///
323/// Every consumer threads the `?` short-circuit onto its handler's
324/// `Result<_, anyhow::Error>` return — dropping the guard swallows
325/// the underlying type-mismatch entirely, which is never the intended
326/// semantic at either pre-lift consumer (each downstream
327/// `.entry(...).or_insert_with(...)` / `.insert(...)` mutation
328/// depends on the returned `&mut Map` reference).
329///
330/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
331/// 3-line `.entry(<slot>).or_insert_with(|| Value::Object(<empty>))
332/// .as_object_mut_or(<slot>)?` compound shape recurred at two
333/// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
334/// trigger, and is lifted to ONE substrate owner here). THEORY.md
335/// §II.1 invariant 5 (composition preserves proofs — a regression
336/// that drifted the entry-key slot vs. the guard-error slot at ONE
337/// site would silently pass one downstream pin and fail the other;
338/// post-lift the primitive stamps the slot ONCE per call so the
339/// substrate itself owns the entry-key ↔ guard-error name coherence).
340pub trait JsonMapObjectEntryExt {
341 /// Get-or-seed the object at `slot` in this JSON map, then guard
342 /// that the resulting handle is an object; returns
343 /// `&mut Map<String, Value>` on the object arm, and an
344 /// [`anyhow::Error`] whose `Display` reads
345 /// `"<slot> is not an object"` on the non-object arm (byte-
346 /// identical to the pre-lift `.as_object_mut_or(<slot>)?` guard,
347 /// sourced from the sibling [`ValueObjectExt::as_object_mut_or`]).
348 #[must_use = "an object-slot guard that isn't threaded via `?` swallows the underlying type mismatch"]
349 fn object_slot_mut_or(&mut self, slot: &'static str)
350 -> anyhow::Result<&mut Map<String, Value>>;
351}
352
353impl JsonMapObjectEntryExt for Map<String, Value> {
354 #[inline]
355 fn object_slot_mut_or(
356 &mut self,
357 slot: &'static str,
358 ) -> anyhow::Result<&mut Map<String, Value>> {
359 self.entry(slot)
360 .or_insert_with(|| Value::Object(Map::new()))
361 .as_object_mut_or(slot)
362 }
363}
364
365/// Substrate extension trait over `serde_json::Value` — the ONE
366/// substrate owner of the paired `.get(<key>).and_then(|v| v.as_<T>())`
367/// two-link READ chain every downstream projection walks to pull a
368/// typed leaf off a Kubernetes-status blob (or an equivalent
369/// rendered-resource JSON object) without asserting the slot is
370/// present, without asserting its variant, and without asserting the
371/// slot fits the target scalar type.
372///
373/// The trait carries ONE method per typed READ axis; the axis-family
374/// is [`Self::get_i64`] (integer counters) + [`Self::get_str`]
375/// (string slots) + [`Self::get_array`] (JSON array slots) +
376/// [`Self::get_bool`] (boolean flags). Adding a further axis
377/// (a `get_object` for `Value::Object`, a `get_f64` for
378/// `Value::Number` truncated to `f64`) lands as ONE new method here
379/// + ONE impl arm per receiver shape, inheriting the naming,
380/// `#[must_use]`, and inline discipline the existing axes pin.
381/// Never open a peer trait for a new axis — keep every READ
382/// projection on the ONE substrate owner so a caller who imports
383/// `ValueGetExt` reaches every axis through the same trait handle.
384///
385/// READ-side counterpart to the three MUTATION-side siblings already in
386/// this module — [`ValueObjectExt::as_object_mut_or`],
387/// [`JsonMapStrExt::insert_str`], [`JsonMapObjectEntryExt::object_slot_mut_or`]
388/// — partitioning the substrate along the (read, mutate) axis on the
389/// same `serde_json::Value` / `serde_json::Map<String, Value>` carrier
390/// pair.
391///
392/// Pre-lift the two-link chain was hand-authored at THREE adjacent
393/// slots inside `tatara-reconciler::boundary::fetch_job_status`, each
394/// projecting one `batch/v1::Job` `status.<counter>` field out of the
395/// fetched `serde_json::Value` object into a private `JobStatusView`
396/// row past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
397///
398/// * `status.get("succeeded").and_then(|v| v.as_i64())` — the
399/// Job-completion counter every `JobAttested` + `ClosedLoopAuth`
400/// postcondition evaluator gates on (`succeeded < 1` short-circuits
401/// to `Satisfaction::Unsatisfied("… still running (…)")`).
402/// * `status.get("failed").and_then(|v| v.as_i64())` — the
403/// Job-failure counter the same evaluators gate on
404/// (`failed > 0` short-circuits to
405/// `Satisfaction::Unsatisfied("… failed (status.failed={n})")`).
406/// * `status.get("active").and_then(|v| v.as_i64())` — the
407/// Job-in-flight counter the "still running" diagnostic tail
408/// reports as `(succeeded={s}, active={a})`.
409///
410/// All THREE sites walked the SAME two-link chain — `.get(<key>)` on a
411/// `serde_json::Value` already known to be the status object, then
412/// `.and_then(|v| v.as_i64())` on the returned `Option<&Value>` — and
413/// each was followed by an `if let Some(...)` write into the
414/// [`JobStatusView`] row initialised from `Default::default()`. Post-
415/// lift each callsite reads `status.get_i64(<key>)` and the two-link
416/// READ chain lives at ONE substrate owner here.
417///
418/// ### Naming — `get_i64`, not `as_i64` or `i64_at`
419///
420/// Same discipline as the three sibling traits above — the trait method
421/// deliberately does NOT collide with `serde_json::Value::as_i64` (the
422/// inherent projection on a single `Value` handle) nor with
423/// `serde_json::Value::get` (the inherent slot-lookup returning
424/// `Option<&Value>`). A name collision would let a caller who has
425/// `ValueGetExt` in scope resolve to one of the inherent methods by
426/// accident (inherent methods win over trait methods in method
427/// resolution) and silently drop half of the paired chain. The
428/// `get_i64(<key>)` shape names the intent: look up the slot at
429/// `<key>`, project the returned handle to `i64`, in ONE call.
430///
431/// ### `#[must_use]`
432///
433/// Every consumer either binds the returned `Option<i64>` into a
434/// downstream `if let Some(n) = ...` / `.unwrap_or_default()` / struct-
435/// field construction. Dropping the return silently discards the
436/// projection entirely, which is never the intended semantic at the
437/// three pre-lift consumers (each downstream write depends on the
438/// returned counter).
439///
440/// ### Composability
441///
442/// * Key slot is `&str` — matches every pre-lift `.get("<literal>")`
443/// callsite and the inherent `serde_json::Value::get`'s primary
444/// `str`-index arm. A caller with a runtime-computed key (a
445/// `String` produced by a template composer) reaches through
446/// `.get_i64(&s)` mechanically via `Deref<Target = str>`.
447/// * Returns `Option<i64>` matching the composed inherent chain's own
448/// return; a consumer wanting the "absent or non-integer → 0"
449/// fallback composes `.unwrap_or_default()` (or `.unwrap_or(0)`) at
450/// the callsite, keeping the "should this counter default to 0 or
451/// fail loud" decision at the caller rather than baking it into the
452/// primitive.
453/// * Non-object receivers (a `Value::String`, a `Value::Null`) return
454/// `None` verbatim via the inherent `Value::get`'s own non-object-
455/// arm behaviour, matching the pre-lift chain's semantics on the
456/// corner where the caller's status blob is malformed.
457///
458/// A future normalization — a per-fleet clamp that rejects negative
459/// counters (the K8s API server never emits them, but a fixture
460/// authoring bug could), a `Value::Number` fallback that accepts
461/// `f64` counters truncated to `i64`, a `checked` overflow arm that
462/// promotes an out-of-range integer to a diagnostic rather than a
463/// silent `None` — lands at THIS ONE substrate primitive and every
464/// downstream Job-status / Deployment-replica / HPA-desired-count
465/// counter reader inherits the upgrade mechanically. No per-site edit
466/// at any of the 3 listed callers or at future consumers (a
467/// Deployment `readyReplicas` projection, an HPA `currentReplicas`
468/// gate, a StatefulSet `updatedReplicas` freshness check).
469///
470/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
471/// two-link `.get(<key>).and_then(|v| v.as_i64())` chain recurred at
472/// three hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
473/// duplication trigger, and is lifted to ONE substrate owner here).
474/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
475/// regression that drifted the projection axis at ONE site — a swap
476/// of `as_i64` for `as_u64` narrowing the accepted range, a swap of
477/// `.get(<key>)` for `.pointer("<key>")` losing the direct-child
478/// semantics — would silently pass every downstream `JobStatusView`
479/// composition and surface as a wrong counter at operator-facing
480/// diagnostic wording; post-lift the projection lives at ONE typed
481/// owner so a regression surfaces at [`tests::get_i64_null_arm_returns_none`]
482/// / peers rather than as silent operator-facing drift).
483pub trait ValueGetExt {
484 /// Look up `key` on this JSON object and project the returned
485 /// handle to `i64`; returns `None` when the slot is absent, when
486 /// the receiver is not a JSON object, or when the slot's variant
487 /// is not integer-shaped.
488 #[must_use = "a JSON i64 projection that isn't bound swallows the counter entirely"]
489 fn get_i64(&self, key: &str) -> Option<i64>;
490
491 /// Look up `key` on this JSON object and project the returned
492 /// handle to `&str`; returns `None` when the slot is absent, when
493 /// the receiver is not a JSON object, or when the slot's variant
494 /// is not `Value::String`.
495 ///
496 /// String-axis sibling of [`Self::get_i64`] on the same
497 /// `.get(<key>).and_then(|v| v.as_<T>())` READ-chain lift. Pre-lift
498 /// the two-link chain was hand-authored at SEVEN production sites
499 /// across two crates past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
500 /// threshold:
501 ///
502 /// * `tatara-process::status::RenderedResourceCoords::from_json`
503 /// — FOUR paired reads (`apiVersion`, `kind`, `metadata.name`,
504 /// `metadata.namespace`) that project the four rendered-resource
505 /// coordinate slots off a `serde_json::Value` rendered manifest
506 /// into the typed `RenderedResourceCoords` row; the required
507 /// three (`apiVersion` / `kind` / `metadata.name`) compose with
508 /// `.ok_or_else(|| anyhow!("rendered resource missing X"))?
509 /// .to_string()`, and the optional `metadata.namespace` composes
510 /// with `.map(str::to_string)`.
511 /// * `tatara-reconciler::ssapply::ready_condition_value` — THREE
512 /// paired reads (`type`, `status`, `message`) inside the
513 /// condition-walker's per-condition classifier, each pulling a
514 /// `Value::String` slot off a K8s Condition object off the
515 /// `status.conditions[]` array.
516 ///
517 /// All seven sites walked the SAME two-link chain — `.get(<key>)`
518 /// on a `serde_json::Value` already known to be an object, then
519 /// `.and_then(|v| v.as_str())` on the returned `Option<&Value>` —
520 /// and each composed different downstream tails (fallible
521 /// `.ok_or_else(...)?.to_string()`, optional `.map(String::from)`,
522 /// pattern-match `Some("True")` / `Some("False")` / `_`). Post-lift
523 /// each callsite reads `<value>.get_str(<key>)` and the two-link
524 /// READ chain lives at ONE substrate owner here.
525 ///
526 /// ### Naming — `get_str`, not `as_str` or `str_at`
527 ///
528 /// Same discipline as [`Self::get_i64`] — the trait method
529 /// deliberately does NOT collide with `serde_json::Value::as_str`
530 /// (the inherent projection on a single `Value` handle) nor with
531 /// `serde_json::Value::get` (the inherent slot-lookup returning
532 /// `Option<&Value>`). A name collision would let a caller who has
533 /// [`ValueGetExt`] in scope resolve to one of the inherent methods
534 /// by accident (inherent methods win over trait methods in method
535 /// resolution) and silently drop half of the paired chain. The
536 /// `get_str(<key>)` shape names the intent: look up the slot at
537 /// `<key>`, project the returned handle to `&str`, in ONE call.
538 ///
539 /// ### `#[must_use]`
540 ///
541 /// Every pre-lift consumer binds the returned `Option<&str>` into
542 /// a downstream `.ok_or_else(...)?.to_string()` / `.map(String::from)`
543 /// / `.map(str::to_string)` / pattern-match arm. Dropping the
544 /// return silently discards the projection entirely, which is
545 /// never the intended semantic at any of the seven pre-lift
546 /// consumers.
547 ///
548 /// ### Return lifetime
549 ///
550 /// The `&str` borrows the same buffer the underlying
551 /// `Value::String` variant owns; the `Option<&str>` is bounded by
552 /// the receiver's lifetime (`&'_ self`), so a caller holding onto
553 /// the returned slice keeps the receiver borrowed. Matches the
554 /// pre-lift chain's own borrow shape (`v.as_str()` borrows through
555 /// the `&Value`).
556 ///
557 /// A future normalization on the projection — a Unicode
558 /// normalization pass (NFC-folding annotation values), a
559 /// per-fleet trim of leading/trailing whitespace, a rejection of
560 /// empty-string arms as "the caller meant absent" — lands at THIS
561 /// ONE substrate primitive and every downstream `apiVersion` /
562 /// `kind` / `metadata.name` / K8s-condition-string reader
563 /// inherits the upgrade mechanically.
564 ///
565 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
566 /// the two-link `.get(<key>).and_then(|v| v.as_str())` chain
567 /// recurred at SEVEN production sites across two crates past the
568 /// ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
569 /// ONE substrate owner here on the string axis of the same
570 /// READ-chain axis-family the `get_i64` sibling opened for the
571 /// integer axis). THEORY.md §II.1 invariant 5 (composition
572 /// preserves proofs — a regression that drifted the projection
573 /// axis at ONE site would silently pass every downstream
574 /// composition and surface as a wrong slot at operator-facing
575 /// diagnostic wording; post-lift the projection lives at ONE
576 /// typed owner so a regression surfaces at
577 /// [`tests::get_str_present_string_slot_returns_the_slice`] /
578 /// peers rather than as silent operator-facing drift).
579 #[must_use = "a JSON &str projection that isn't bound swallows the slot entirely"]
580 fn get_str(&self, key: &str) -> Option<&str>;
581
582 /// Look up `key` on this JSON object and project the returned
583 /// handle to `&Vec<Value>`; returns `None` when the slot is
584 /// absent, when the receiver is not a JSON object, or when the
585 /// slot's variant is not `Value::Array`.
586 ///
587 /// Array-axis sibling of [`Self::get_i64`] + [`Self::get_str`]
588 /// on the same `.get(<key>).and_then(|v| v.as_<T>())` READ-chain
589 /// axis-family. Pre-lift the two-link chain was hand-authored at
590 /// TWO production sites across two crates past the ★★
591 /// PRIME-DIRECTIVE ≥ 2 duplication threshold:
592 ///
593 /// * `tatara-reconciler::ssapply::ready_condition_value` — the
594 /// tail of the `data.get("status").and_then(|s|
595 /// s.get("conditions")).and_then(|c| c.as_array())` walker that
596 /// opens the K8s Condition classifier every DynamicObject
597 /// readiness probe rides through.
598 /// * `tatara-closed-loop-probe::probe::count_jwks_keys` — the
599 /// JWKS-response walker that counts issuer-side public keys off
600 /// the `keys` slot for the closed-loop probe's per-run
601 /// `jwks_key_count` diagnostic.
602 ///
603 /// Both sites walked the SAME two-link chain — `.get(<key>)` on a
604 /// `serde_json::Value` already known to be an object, then
605 /// `.and_then(|v| v.as_array())` on the returned `Option<&Value>`
606 /// — and composed different downstream tails (`Some(conditions)`
607 /// pattern-match on the reconciler side, `.map(|xs| xs.len() as
608 /// u64)` on the probe side). Post-lift each callsite reads
609 /// `<value>.get_array(<key>)` and the two-link READ chain lives at
610 /// ONE substrate owner here. The probe-side variant additionally
611 /// sheds the pre-lift `.get("keys").cloned()` allocation because
612 /// this primitive borrows through the receiver rather than
613 /// cloning.
614 ///
615 /// ### Naming — `get_array`, not `as_array` or `array_at`
616 ///
617 /// Same discipline as [`Self::get_i64`] + [`Self::get_str`] — the
618 /// trait method deliberately does NOT collide with
619 /// `serde_json::Value::as_array` (the inherent projection on a
620 /// single `Value` handle) nor with `serde_json::Value::get` (the
621 /// inherent slot-lookup returning `Option<&Value>`). A name
622 /// collision would let a caller who has [`ValueGetExt`] in scope
623 /// resolve to one of the inherent methods by accident (inherent
624 /// methods win over trait methods in method resolution) and
625 /// silently drop half of the paired chain. The `get_array(<key>)`
626 /// shape names the intent: look up the slot at `<key>`, project
627 /// the returned handle to `&Vec<Value>`, in ONE call.
628 ///
629 /// ### `#[must_use]`
630 ///
631 /// Every pre-lift consumer binds the returned `Option<&Vec<Value>>`
632 /// into a downstream `let Some(...) = ... else { return ... }`
633 /// short-circuit or a `.map(|xs| xs.len() as u64).unwrap_or(0)`
634 /// counter composition. Dropping the return silently discards the
635 /// projection entirely, which is never the intended semantic at
636 /// either pre-lift consumer.
637 ///
638 /// ### Return lifetime
639 ///
640 /// The `&Vec<Value>` borrows the same buffer the underlying
641 /// `Value::Array` variant owns; the `Option<&Vec<Value>>` is
642 /// bounded by the receiver's lifetime (`&'_ self`), so a caller
643 /// iterating the returned slice keeps the receiver borrowed.
644 /// Matches the pre-lift chain's own borrow shape (`v.as_array()`
645 /// borrows through the `&Value`), and in the probe.rs case
646 /// eliminates the pre-lift `.cloned()` on the intermediate
647 /// `Value` that only existed to sidestep the borrow.
648 ///
649 /// A future normalization on the projection — a rejection of
650 /// empty arrays as "the caller meant absent", an accept-scalar
651 /// coercion (a `Value::String` promoted to a one-element array),
652 /// a per-fleet cap on array length that short-circuits pathological
653 /// payloads — lands at THIS ONE substrate primitive and every
654 /// downstream K8s-Condition classifier / JWKS-array counter /
655 /// future array-slot reader inherits the upgrade mechanically.
656 ///
657 /// Theory anchor: THEORY.md §VI.1 (generation over composition —
658 /// the two-link `.get(<key>).and_then(|v| v.as_array())` chain
659 /// recurred at two production sites across two crates past the ★★
660 /// PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE
661 /// substrate owner here on the array axis of the same READ-chain
662 /// axis-family the `get_i64` + `get_str` siblings already own).
663 /// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
664 /// regression that drifted the projection axis at ONE site would
665 /// silently pass every downstream composition and surface as a
666 /// wrong slot at operator-facing diagnostic wording; post-lift the
667 /// projection lives at ONE typed owner so a regression surfaces
668 /// at [`tests::get_array_present_array_slot_returns_the_slice`] /
669 /// peers rather than as silent operator-facing drift).
670 #[must_use = "a JSON array projection that isn't bound swallows the slot entirely"]
671 fn get_array(&self, key: &str) -> Option<&Vec<Value>>;
672
673 /// Look up `key` on this JSON object and project the returned
674 /// handle to `bool`; returns `None` when the slot is absent, when
675 /// the receiver is not a JSON object, or when the slot's variant
676 /// is not `Value::Bool`.
677 ///
678 /// Boolean-axis sibling of [`Self::get_i64`] / [`Self::get_str`] /
679 /// [`Self::get_array`] on the same `.get(<key>).and_then(|v|
680 /// v.as_<T>())` READ-chain axis-family. Completes the axis-family
681 /// coverage over the four most-common `Value` scalar / collection
682 /// shapes an operator reads out of a K8s status / spec blob or a
683 /// rendered-resource JSON object: integer counter (`succeeded`,
684 /// `failed`, `active`, `replicas`), string slot (`apiVersion`,
685 /// `kind`, `metadata.name`, `type`, `status`, `message`), array
686 /// slot (`conditions`, `finalizers`, `keys`), and boolean flag
687 /// (`controller`, `blockOwnerDeletion`, `spec.suspended`,
688 /// `hostNetwork`, `automountServiceAccountToken`,
689 /// `identity.name_override`).
690 ///
691 /// The axis was named as the next extension point in the
692 /// `ValueGetExt` docstring's own guidance ("Adding a new axis
693 /// (a `get_bool` for `Value::Bool`, …) lands as ONE new method
694 /// here + ONE impl arm"), and this method opens it. A future
695 /// consumer walking a `blockOwnerDeletion` / `controller` bit off
696 /// a K8s OwnerReference JSON, an `identity.name_override` flag off
697 /// a `phase_status_with(phase, "identity", …)` patch body, or a
698 /// `spec.suspended` gate off a SIGSTOP-driven spec toggle reaches
699 /// this substrate rather than re-authoring the two-link
700 /// `.get(<key>).and_then(|v| v.as_bool())` chain by hand.
701 ///
702 /// ### Naming — `get_bool`, not `as_bool` or `bool_at`
703 ///
704 /// Same discipline as the three sibling axes — the trait method
705 /// deliberately does NOT collide with `serde_json::Value::as_bool`
706 /// (the inherent projection on a single `Value` handle) nor with
707 /// `serde_json::Value::get` (the inherent slot-lookup returning
708 /// `Option<&Value>`). A name collision would let a caller who has
709 /// [`ValueGetExt`] in scope resolve to one of the inherent methods
710 /// by accident (inherent methods win over trait methods in method
711 /// resolution) and silently drop half of the paired chain. The
712 /// `get_bool(<key>)` shape names the intent: look up the slot at
713 /// `<key>`, project the returned handle to `bool`, in ONE call.
714 ///
715 /// ### `#[must_use]`
716 ///
717 /// Every consumer either binds the returned `Option<bool>` into a
718 /// downstream `if let Some(b) = ...` gate, a
719 /// `.unwrap_or_default()` / `.unwrap_or(false)` fallback, or a
720 /// pattern-match arm. Dropping the return silently discards the
721 /// projection entirely, which is never the intended semantic at
722 /// any downstream boolean-flag consumer.
723 ///
724 /// ### Composability
725 ///
726 /// * Key slot is `&str` — matches the sibling axes verbatim;
727 /// `&'static str` literals and runtime-composed `String`
728 /// handles both coerce.
729 /// * Returns `Option<bool>` matching the composed inherent chain's
730 /// own return; a consumer wanting the "absent or non-bool →
731 /// false" fallback composes `.unwrap_or_default()` (or
732 /// `.unwrap_or(false)`) at the callsite, keeping the "should
733 /// this flag default to false or fail loud" decision at the
734 /// caller rather than baking it into the primitive.
735 /// * Non-object receivers (a `Value::String`, a `Value::Null`)
736 /// return `None` verbatim via the inherent `Value::get`'s own
737 /// non-object-arm behaviour, matching the pre-lift chain's
738 /// semantics on the corner where the caller's status blob is
739 /// malformed.
740 ///
741 /// A future normalization on the projection — a stricter
742 /// `Value::String("true")` / `Value::String("false")` coercion for
743 /// K8s wire-form drift (K8s occasionally serialises booleans as
744 /// stringified values in edge cases), a per-fleet default policy
745 /// for the absent-slot corner, a `checked` corner that fails loud
746 /// on `Value::Number(0)` / `Value::Number(1)` coercion attempts —
747 /// lands at THIS ONE substrate primitive and every downstream
748 /// boolean-flag reader inherits the upgrade mechanically. No
749 /// per-site edit at any consumer that adopts this primitive.
750 ///
751 /// Theory anchor: THEORY.md §II.1 invariant 5 (composition
752 /// preserves proofs — the projection lives at ONE typed owner on
753 /// the same axis-family the three sibling axes already open; a
754 /// regression that drifted the projection axis at ONE site would
755 /// silently pass every downstream composition and surface as a
756 /// wrong flag at operator-facing gate wording). THEORY.md §III
757 /// (typescape — the axis-family completes coverage over the four
758 /// most-common `Value` shapes any K8s status / spec / manifest
759 /// reader projects, so a caller who imports `ValueGetExt` reaches
760 /// integer counters, string slots, array slots, AND boolean
761 /// flags through ONE trait handle).
762 #[must_use = "a JSON bool projection that isn't bound swallows the flag entirely"]
763 fn get_bool(&self, key: &str) -> Option<bool>;
764}
765
766impl ValueGetExt for Value {
767 #[inline]
768 fn get_i64(&self, key: &str) -> Option<i64> {
769 self.get(key).and_then(Value::as_i64)
770 }
771
772 #[inline]
773 fn get_str(&self, key: &str) -> Option<&str> {
774 self.get(key).and_then(Value::as_str)
775 }
776
777 #[inline]
778 fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
779 self.get(key).and_then(Value::as_array)
780 }
781
782 #[inline]
783 fn get_bool(&self, key: &str) -> Option<bool> {
784 self.get(key).and_then(Value::as_bool)
785 }
786}
787
788/// Receiver-shape widening of the READ-projection axis-family — the
789/// same three methods extended from `Value` (the `Value::Object` arm's
790/// walker) to `Map<String, Value>` (the object interior itself),
791/// closing the receiver-shape gap so a caller who already holds an
792/// `&Map<String, Value>` handle (via `.as_object().unwrap()`, via
793/// [`ValueObjectExt::as_object_mut_or`], via the two `JsonMap*Ext`
794/// siblings' returns, or via a helper like `ssapply::ownership_kv_pair`
795/// that composes and returns a `Map` directly) reaches the SAME
796/// `get_i64` / `get_str` / `get_array` methods without a
797/// `Value::Object(m)` rewrap detour.
798///
799/// Pre-lift the `.get(<key>).and_then(Value::as_<T>)` two-link chain
800/// was hand-authored at 30 `Map<String, Value>`-receiver sites past the
801/// ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold — 19 in
802/// `tatara-reconciler::patch` tests (the phase-status wire-shape pins
803/// walking `obj = v.as_object().unwrap()` and `metadata = obj.get(
804/// "metadata").and_then(Value::as_object).unwrap()` receivers) plus
805/// 11 in `tatara-reconciler::ssapply` tests (the ownership-tag +
806/// composed-coord pins walking the `Map` handles returned by
807/// `ownership_annotations` / `ownership_labels` /
808/// `ownership_annotations_by_coord`). Post-lift each callsite reads
809/// `<map>.get_str(<key>)` / `<map>.get_array(<key>)` and the READ
810/// chain rides through the SAME substrate owner the `Value`-receiver
811/// callers already threaded through.
812///
813/// The axis-family invariant (a caller who imports `ValueGetExt`
814/// reaches every axis through the same trait handle — pinned at
815/// [`tests::get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import`],
816/// its `get_str` sibling, and the fourth-axis sibling
817/// [`tests::get_bool_axis_family_reaches_i64_str_array_and_bool_through_one_trait_import`])
818/// extends verbatim to the `Map` receiver: a single
819/// `use tatara_process::json_object::ValueGetExt;` unlocks every
820/// axis on both receiver shapes. A future new axis (e.g. a
821/// `get_object` for `Value::Object` slots, a `get_f64` for
822/// `Value::Number` truncated to `f64`) adds one method on the trait
823/// and inherits both impls; there is no separate `MapGetExt` peer to
824/// keep in sync.
825///
826/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
827/// two-link chain recurred at 30 `Map`-receiver sites past the ★★
828/// PRIME-DIRECTIVE ≥ 2 duplication trigger, and rides through the
829/// same substrate owner the pre-existing `Value`-receiver impl above
830/// already pinned). THEORY.md §II.1 invariant 5 (composition preserves
831/// proofs — the receiver-shape widening carries the axis-family
832/// invariant across without splitting it into two traits).
833impl ValueGetExt for Map<String, Value> {
834 #[inline]
835 fn get_i64(&self, key: &str) -> Option<i64> {
836 self.get(key).and_then(Value::as_i64)
837 }
838
839 #[inline]
840 fn get_str(&self, key: &str) -> Option<&str> {
841 self.get(key).and_then(Value::as_str)
842 }
843
844 #[inline]
845 fn get_array(&self, key: &str) -> Option<&Vec<Value>> {
846 self.get(key).and_then(Value::as_array)
847 }
848
849 #[inline]
850 fn get_bool(&self, key: &str) -> Option<bool> {
851 self.get(key).and_then(Value::as_bool)
852 }
853}
854
855#[cfg(test)]
856mod tests {
857 use super::*;
858 use serde_json::json;
859
860 // ─── ValueObjectExt::as_object_mut_or substrate pins ─────────────
861 //
862 // Fail-before-pass-after granularity: the `ValueObjectExt::
863 // as_object_mut_or` trait method did not exist before this commit,
864 // so each test below fails to compile pre-lift. Post-lift they
865 // collectively pin the object-guard shape at ONE substrate owner —
866 // a regression that drifts the error message wording, swaps the
867 // `<slot>` slot, wraps the source in a chain-form `source` (which
868 // would change `Display` output when downstream tracing formatters
869 // interpolate `{e}` rather than the chain-walking `{e:#}`), or
870 // promotes the pass-through arm to synthesis (a `None → Ok(&mut
871 // Map::default())` fallthrough that silently swallows a mistyped
872 // slot) surfaces HERE rather than as silent operator-facing skew
873 // across the three `ssapply.rs` pre-lift consumers whose log
874 // output already encoded the flat `"<slot> is not an object"`
875 // shape.
876
877 #[test]
878 fn as_object_mut_or_object_arm_returns_the_inner_map_mutably() {
879 // Ok-arm invariant: a `Value::Object` handle threaded through
880 // `as_object_mut_or("<slot>")` MUST return `Ok(&mut Map)`
881 // whose interior is the SAME `serde_json::Map` the underlying
882 // `serde_json::Value::as_object_mut` would return — no clone,
883 // no reshape, no synthesis. The `&mut` return is load-bearing
884 // at every consumer (each threads a downstream `.entry(...).
885 // or_insert_with(...)` / `.insert(...)` mutation onto the
886 // returned reference), so a regression that returned a fresh
887 // owned `Map` here would silently drop every downstream write.
888 let mut v = json!({ "existing_key": "existing_value" });
889 let map = v.as_object_mut_or("resource").expect("Value::Object");
890 map.insert("new_key".to_string(), json!("new_value"));
891 assert_eq!(v["existing_key"], "existing_value");
892 assert_eq!(v["new_key"], "new_value");
893 }
894
895 #[test]
896 fn as_object_mut_or_null_arm_errors_with_pre_lift_display_bytewise() {
897 // Byte-shape parity pin: the wrap output of `as_object_mut_or
898 // ("<slot>")` on a `Value::Null` handle MUST be `Display`-
899 // identical to the pre-lift hand-authored `.as_object_mut().
900 // ok_or_else(|| anyhow!("<slot> is not an object"))?` chain.
901 // A regression that inserted a synonym (`"<slot> is not a
902 // JSON object"`), reshaped the slot position (`"not an
903 // object: <slot>"`), or dropped the leading `<slot>` slot
904 // surfaces HERE rather than as silent drift at every
905 // downstream log-output consumer.
906 let mut v = Value::Null;
907 let err = v.as_object_mut_or("resource").unwrap_err();
908 assert_eq!(format!("{err}"), "resource is not an object");
909 }
910
911 #[test]
912 fn as_object_mut_or_array_arm_errors_with_pre_lift_display_bytewise() {
913 // Sibling to the null-arm byte-shape pin — a mistyped
914 // `metadata` slot authored as a JSON array (kubectl accepts
915 // `metadata: []` in a YAML manifest with no schema, though the
916 // apiserver later rejects it) surfaces the same guard error.
917 // Pins the "non-object variants ALL error via the same wire
918 // format" invariant — a regression that special-cased the
919 // array variant (returning a fresh empty map, silently
920 // coercing) surfaces HERE.
921 let mut v = json!(["not", "an", "object"]);
922 let err = v.as_object_mut_or("metadata").unwrap_err();
923 assert_eq!(format!("{err}"), "metadata is not an object");
924 }
925
926 #[test]
927 fn as_object_mut_or_string_arm_errors_with_pre_lift_display_bytewise() {
928 // Sibling to the null / array pins — a mistyped `annotations`
929 // slot authored as a JSON string (a common apiserver-layer
930 // authoring bug in kubectl-generated manifests where a
931 // stringified JSON object leaks through) surfaces the same
932 // guard error. Pins the "every non-object variant errors via
933 // the same wire format" invariant across the full
934 // `serde_json::Value` sum.
935 let mut v = json!("stringified");
936 let err = v.as_object_mut_or("annotations").unwrap_err();
937 assert_eq!(format!("{err}"), "annotations is not an object");
938 }
939
940 #[test]
941 fn as_object_mut_or_threads_the_slot_slug_verbatim_across_all_three_pre_lift_labels() {
942 // Cross-slot coherence pin: the three pre-lift consumers in
943 // `tatara-reconciler::ssapply` stamped THREE distinct slot
944 // slugs (`"resource"` / `"metadata"` / `"annotations"`), and
945 // the wrap-shape MUST honor each one verbatim as the leading
946 // slot in the `Display` output. A regression that hard-coded
947 // one slug (say `"resource"`) across every callsite would
948 // pass the first pin above and fail HERE — the three
949 // downstream error-stream greps operators run to bisect a
950 // "which SSA-time mutation faulted" alert would ALL collapse
951 // to the same slug.
952 for slot in ["resource", "metadata", "annotations"] {
953 let mut v = Value::Null;
954 let err = v.as_object_mut_or(slot).unwrap_err();
955 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
956 }
957 }
958
959 #[test]
960 fn as_object_mut_or_object_arm_matches_inherent_as_object_mut_bytewise() {
961 // Cross-substrate coherence pin: on the Ok arm the trait
962 // method MUST return the SAME `&mut Map` the inherent
963 // `serde_json::Value::as_object_mut` would — no diverging
964 // view, no clone, no key-order reshape. A regression that
965 // introduced a normalization pass here (sorting keys,
966 // stripping a null-valued entry, coercing a nested string
967 // to a JSON scalar) would surface as silent per-consumer
968 // schema drift at the SSA-time mutation — an ownerReferences
969 // append that no longer landed in the same slot the apiserver
970 // reads, an annotations insert whose key ordering diverged
971 // from kubectl's canonical form.
972 let mut via_trait = json!({ "key": "value", "nested": { "inner": 1 } });
973 let mut via_inherent = via_trait.clone();
974 assert_eq!(
975 via_trait
976 .as_object_mut_or("resource")
977 .expect("Value::Object")
978 .clone(),
979 via_inherent.as_object_mut().expect("Value::Object").clone(),
980 );
981 }
982
983 // ─── JsonMapStrExt::insert_str substrate pins ─────────────────
984 //
985 // Fail-before-pass-after granularity: the `JsonMapStrExt::insert_str`
986 // trait method did not exist before this commit, so each test below
987 // fails to compile pre-lift. Post-lift they collectively pin the
988 // string-slot write shape at ONE substrate owner — a regression that
989 // dropped the `Value::String` wrap (silently coercing to a bare
990 // `Value::from(&str)` — byte-identical in the `Object` arm today but
991 // divergent for any future non-`&str` numeric caller who reached for
992 // `insert_str(k, n.to_string())`), swapped the key + value slot
993 // orientation, or drifted the return semantics from the inherent
994 // `Map::insert` (which returns the previous value on overwrite —
995 // load-bearing at any future caller that inspects the return) would
996 // surface HERE rather than as silent per-emit skew across the
997 // thirteen pre-lift `ssapply` + `render` + `edges` consumers.
998
999 #[test]
1000 fn insert_str_new_key_returns_none_and_stamps_value_string() {
1001 // New-key arm: matches inherent `Map::insert` return
1002 // semantics — `None` for a fresh key — and stamps a
1003 // `Value::String` (NOT `Value::from(&str)`, though they're
1004 // byte-identical today) at the slot.
1005 let mut m = Map::new();
1006 let prev = m.insert_str("key", "value");
1007 assert!(prev.is_none(), "new key returns None");
1008 assert_eq!(m.get("key"), Some(&Value::String("value".to_string())));
1009 assert!(matches!(m.get("key"), Some(Value::String(_))));
1010 }
1011
1012 #[test]
1013 fn insert_str_overwrite_returns_prior_value_and_stamps_new() {
1014 // Overwrite arm: matches inherent `Map::insert` return
1015 // semantics — `Some(prev)` on overwrite. Load-bearing for
1016 // any future consumer that inspects the return to detect a
1017 // slot collision (a fleet-wide sweep that flagged a
1018 // duplicate SSA-time annotation stamp, for example).
1019 let mut m = Map::new();
1020 m.insert_str("key", "old");
1021 let prev = m.insert_str("key", "new");
1022 assert_eq!(prev, Some(Value::String("old".to_string())));
1023 assert_eq!(m.get("key"), Some(&Value::String("new".to_string())));
1024 }
1025
1026 #[test]
1027 fn insert_str_accepts_str_and_owned_string_at_both_slots() {
1028 // Composability pin: both slots MUST accept `&str` and
1029 // `String` interchangeably — the pre-lift callsite inventory
1030 // mixes both (SSA-time `annotations::PID` static + a
1031 // `pid.to_string()` runtime String at the value slot;
1032 // `spec.insert("interval".into(), Value::String("1m".into()))`
1033 // with two `&str` slots). A regression that constrained
1034 // either slot to one shape would break the callsite parity
1035 // that motivated this substrate primitive.
1036 let mut m1 = Map::new();
1037 m1.insert_str("a", "b");
1038 let mut m2 = Map::new();
1039 m2.insert_str(String::from("a"), String::from("b"));
1040 let mut m3 = Map::new();
1041 m3.insert_str("a", String::from("b"));
1042 let mut m4 = Map::new();
1043 m4.insert_str(String::from("a"), "b");
1044 assert_eq!(m1, m2);
1045 assert_eq!(m2, m3);
1046 assert_eq!(m3, m4);
1047 }
1048
1049 #[test]
1050 fn insert_str_matches_pre_lift_hand_authored_shape_bytewise() {
1051 // Byte-shape parity pin: `insert_str(k, v)` MUST emit the
1052 // SAME `Map` entry the pre-lift hand-authored `.insert(
1053 // <k>.into(), Value::String(<v>.into()))` chain produced.
1054 // Sweeps the four (str × String) × (str × String) key/value
1055 // shape quadrants so a regression at the primitive that
1056 // broke the byte identity with the pre-lift shape at ONE
1057 // quadrant surfaces here rather than as a subtle per-emit
1058 // divergence at that quadrant.
1059 for (k_str, v_str) in [("a", "b"), ("x", ""), ("", "y"), ("", "")] {
1060 // (str, str) quadrant
1061 let mut via_primitive = Map::new();
1062 via_primitive.insert_str(k_str, v_str);
1063 let mut via_pre_lift = Map::new();
1064 via_pre_lift.insert(k_str.into(), Value::String(v_str.into()));
1065 assert_eq!(via_primitive, via_pre_lift);
1066
1067 // (String, String) quadrant
1068 let mut via_primitive = Map::new();
1069 via_primitive.insert_str(String::from(k_str), String::from(v_str));
1070 let mut via_pre_lift = Map::new();
1071 via_pre_lift.insert(String::from(k_str), Value::String(String::from(v_str)));
1072 assert_eq!(via_primitive, via_pre_lift);
1073 }
1074 }
1075
1076 #[test]
1077 fn insert_str_empty_value_stamps_empty_string_not_null() {
1078 // Semantic pin: an empty value slot MUST stamp
1079 // `Value::String("")`, NEVER `Value::Null`. Load-bearing at
1080 // any callsite that stamps a placeholder empty-string
1081 // annotation (say a `content_hash` slot pre-derive) where a
1082 // `Null` slot would fail-loud at the K8s apiserver's
1083 // annotation-value type check.
1084 let mut m = Map::new();
1085 m.insert_str("empty", "");
1086 assert_eq!(m.get("empty"), Some(&Value::String(String::new())));
1087 assert!(!matches!(m.get("empty"), Some(Value::Null)));
1088 }
1089
1090 // ─── JsonMapObjectEntryExt::object_slot_mut_or substrate pins ─────
1091 //
1092 // Fail-before-pass-after granularity: the
1093 // `JsonMapObjectEntryExt::object_slot_mut_or` trait method did not
1094 // exist before this commit, so each test below fails to compile
1095 // pre-lift. Post-lift they collectively pin the compound
1096 // seed-then-guard shape at ONE substrate owner — a regression that
1097 // dropped the seed step (leaving an absent slot to fall through the
1098 // guard as `None → Err`), skipped the guard step (silently returning
1099 // an `&mut Value` when the existing slot is a non-object variant),
1100 // drifted the entry-key slot vs. the guard-error slot (a copy-paste
1101 // typo that stamped `"metadata"` into the entry and `"metadatas"`
1102 // into the guard error message), or drifted the empty-seed shape
1103 // (a `Value::Null` fallback where `Value::Object(Map::new())` is
1104 // load-bearing at the downstream `.entry(...).or_insert_with(...)`
1105 // / `.insert(...)` mutation) would surface HERE rather than as
1106 // silent per-emit skew across the two pre-lift `ssapply.rs`
1107 // consumers.
1108
1109 #[test]
1110 fn object_slot_mut_or_absent_slot_seeds_empty_object_and_returns_it() {
1111 // Absent-slot arm: the pre-lift `.entry(<slot>).or_insert_with
1112 // (|| Value::Object(Default::default()))` step MUST seed the
1113 // slot with an EMPTY `Value::Object` when the slot is not
1114 // present in the parent map. The returned handle is the fresh
1115 // empty map, MUTABLY, so a downstream `.insert(...)` writes
1116 // land in the parent map's `<slot>` object post-return.
1117 let mut parent = Map::new();
1118 {
1119 let child = parent
1120 .object_slot_mut_or("metadata")
1121 .expect("absent slot seeds an object");
1122 assert!(child.is_empty(), "fresh-seeded slot is an empty object");
1123 child.insert("name".into(), Value::String("demo".into()));
1124 }
1125 // The write landed in the parent map's metadata slot.
1126 assert_eq!(parent["metadata"]["name"], "demo");
1127 assert!(matches!(parent.get("metadata"), Some(Value::Object(_))));
1128 }
1129
1130 #[test]
1131 fn object_slot_mut_or_present_object_slot_returns_existing_interior_mutably() {
1132 // Present-object-slot arm: when the slot is already populated
1133 // with a `Value::Object`, the primitive MUST return the
1134 // EXISTING map interior mutably — no synthesis, no reshape, no
1135 // key-order rewrite. The downstream `.insert(...)` writes MUST
1136 // merge into the pre-existing keys rather than replace them.
1137 let mut parent = Map::new();
1138 parent.insert(
1139 "metadata".into(),
1140 serde_json::json!({ "existing_key": "existing_value" }),
1141 );
1142 {
1143 let child = parent
1144 .object_slot_mut_or("metadata")
1145 .expect("present-object slot returns Ok");
1146 assert_eq!(
1147 child.get("existing_key"),
1148 Some(&Value::String("existing_value".into()))
1149 );
1150 child.insert("new_key".into(), Value::String("new_value".into()));
1151 }
1152 assert_eq!(parent["metadata"]["existing_key"], "existing_value");
1153 assert_eq!(parent["metadata"]["new_key"], "new_value");
1154 }
1155
1156 #[test]
1157 fn object_slot_mut_or_present_non_object_slot_errors_with_pre_lift_display() {
1158 // Fail-loud arm: when the slot is present but holds a non-
1159 // object variant (a `Value::String` from a hand-authored
1160 // YAML manifest where `metadata: "malformed"` slipped past
1161 // kubectl's schema check), the primitive MUST fail with a
1162 // `Display` byte-identical to the pre-lift
1163 // `.as_object_mut_or(<slot>)?` guard — the sibling
1164 // [`ValueObjectExt::as_object_mut_or`] guard's wire format.
1165 // A regression that special-cased this arm (overwriting the
1166 // slot with a fresh empty object, silently coercing) would
1167 // silently swallow the operator's authoring error at the
1168 // SSA-time re-injection step.
1169 let mut parent = Map::new();
1170 parent.insert("metadata".into(), Value::String("malformed".into()));
1171 let err = parent.object_slot_mut_or("metadata").unwrap_err();
1172 assert_eq!(format!("{err}"), "metadata is not an object");
1173 }
1174
1175 #[test]
1176 fn object_slot_mut_or_threads_the_slot_slug_verbatim_across_both_pre_lift_labels() {
1177 // Cross-slot coherence pin: the TWO pre-lift consumers in
1178 // `tatara-reconciler::ssapply` stamped TWO distinct slot slugs
1179 // (`"metadata"` at the resource root, `"annotations"` at the
1180 // metadata child), and the wrap-shape MUST honor each one
1181 // verbatim as the leading slot in the `Display` output. A
1182 // regression that hard-coded one slug across every callsite
1183 // would pass the fail-loud pin above (on the `"metadata"` slug)
1184 // and fail HERE — the two downstream error-stream greps
1185 // operators run to bisect a "which SSA-time slot mutation
1186 // faulted" alert would ALL collapse to the same slug, hiding
1187 // whether the fault was at the resource-root object walk or
1188 // the metadata-child annotations walk.
1189 for slot in ["metadata", "annotations"] {
1190 let mut parent = Map::new();
1191 parent.insert(slot.into(), Value::Null);
1192 let err = parent.object_slot_mut_or(slot).unwrap_err();
1193 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
1194 }
1195 }
1196
1197 #[test]
1198 fn object_slot_mut_or_present_empty_object_returns_existing_reference_not_synthesized() {
1199 // Precedence pin: a present slot holding an EMPTY
1200 // `Value::Object` MUST return the pre-existing empty map
1201 // interior — not a freshly-synthesized replacement. The
1202 // pre-lift `.entry(<slot>).or_insert_with(||...)` step's
1203 // short-circuit on the present-slot arm skips the closure
1204 // entirely; a regression that always evaluated the closure
1205 // (unconditionally overwriting an existing empty-object slot
1206 // with a fresh empty object) would type-check silently at
1207 // every callsite AND write byte-identical JSON at the empty-
1208 // slot corner, but it would break a hypothetical future
1209 // consumer that reached the primitive on a map whose slot
1210 // was seeded upstream with metadata (a caller intending to
1211 // preserve any keys the parent-composer already dropped in).
1212 let mut parent = Map::new();
1213 parent.insert("metadata".into(), Value::Object(Map::new()));
1214 let addr_before = parent.get("metadata").unwrap() as *const Value;
1215 {
1216 let _child = parent.object_slot_mut_or("metadata").unwrap();
1217 }
1218 let addr_after = parent.get("metadata").unwrap() as *const Value;
1219 assert_eq!(
1220 addr_before, addr_after,
1221 "present empty-object slot must return the pre-existing reference, not a fresh synthesis",
1222 );
1223 }
1224
1225 #[test]
1226 fn object_slot_mut_or_matches_pre_lift_hand_authored_compound_shape_bytewise() {
1227 // Byte-shape parity pin: `object_slot_mut_or(<slot>)?` MUST
1228 // produce the SAME `&mut Map` (and, on the non-object arm, the
1229 // SAME `Display`-shaped error) the pre-lift 3-line `.entry
1230 // (<slot>).or_insert_with(|| Value::Object(Default::default()))
1231 // .as_object_mut_or(<slot>)?` chain produced. Sweeps the three
1232 // pre-lift-reachable input corners (absent slot / present
1233 // object / present non-object) so a regression at the primitive
1234 // that broke byte identity with the pre-lift chain at ONE
1235 // corner surfaces here rather than as a subtle per-emit
1236 // divergence.
1237 for slot in ["metadata", "annotations"] {
1238 // (1) Absent-slot corner: both routes seed empty-object at
1239 // the slot AND return the same empty map interior.
1240 let mut via_primitive = Map::new();
1241 let mut via_pre_lift = Map::new();
1242 {
1243 let _ = via_primitive.object_slot_mut_or(slot).unwrap();
1244 let _ = via_pre_lift
1245 .entry(slot.to_string())
1246 .or_insert_with(|| Value::Object(Map::new()))
1247 .as_object_mut_or(slot)
1248 .unwrap();
1249 }
1250 assert_eq!(via_primitive, via_pre_lift);
1251
1252 // (2) Present-object corner: both routes read back the
1253 // same pre-populated interior mutably.
1254 let mut via_primitive = Map::new();
1255 via_primitive.insert(slot.into(), serde_json::json!({ "k": "v" }));
1256 let mut via_pre_lift = via_primitive.clone();
1257 {
1258 let a = via_primitive.object_slot_mut_or(slot).unwrap();
1259 let b = via_pre_lift
1260 .entry(slot.to_string())
1261 .or_insert_with(|| Value::Object(Map::new()))
1262 .as_object_mut_or(slot)
1263 .unwrap();
1264 assert_eq!(a, b);
1265 }
1266
1267 // (3) Present-non-object corner: both routes fail loud
1268 // with the same wire-format Display shape.
1269 let mut via_primitive = Map::new();
1270 via_primitive.insert(slot.into(), Value::Bool(true));
1271 let mut via_pre_lift = via_primitive.clone();
1272 let err_primitive = via_primitive.object_slot_mut_or(slot).unwrap_err();
1273 let err_pre_lift = via_pre_lift
1274 .entry(slot.to_string())
1275 .or_insert_with(|| Value::Object(Map::new()))
1276 .as_object_mut_or(slot)
1277 .unwrap_err();
1278 assert_eq!(format!("{err_primitive}"), format!("{err_pre_lift}"));
1279 }
1280 }
1281
1282 // ─── ValueGetExt::get_i64 substrate pins ─────────────────────────
1283 //
1284 // Fail-before-pass-after granularity: the `ValueGetExt::get_i64`
1285 // trait method did not exist before this commit, so each test below
1286 // fails to compile pre-lift. Post-lift they collectively pin the
1287 // paired READ-shape at ONE substrate owner — a regression that
1288 // narrowed the projection to `as_u64` (silently losing every
1289 // negative counter K8s fixtures can carry for a JSON authoring
1290 // bug), swapped the slot lookup to `.pointer(<key>)` (losing the
1291 // direct-child semantics), promoted a present-but-non-integer
1292 // corner to `Some(0)` (silently paving over a malformed status
1293 // blob), or drifted the receiver-non-object arm from `None → Some(default)`
1294 // (silently synthesising a zero counter on a null status blob)
1295 // surfaces HERE rather than as silent operator-facing skew across
1296 // the three `boundary.rs::fetch_job_status` pre-lift consumers
1297 // whose JobStatusView row initialised at `Default::default()` and
1298 // conditionally overwrote each field on `Some(i64)`.
1299
1300 #[test]
1301 fn get_i64_present_integer_slot_returns_the_value() {
1302 // Primary Ok-arm invariant: a `Value::Number(i)` present at the
1303 // slot projects to `Some(i)`. Sweeps the three representative
1304 // counters every pre-lift `JobStatusView` field carried (a
1305 // completed Job's `succeeded=1`, a failed Job's `failed=3`, a
1306 // freshly-scheduled Job's `active=5`) so a regression at ONE
1307 // counter axis surfaces here rather than at the downstream
1308 // diagnostic.
1309 let status = json!({ "succeeded": 1, "failed": 3, "active": 5 });
1310 assert_eq!(status.get_i64("succeeded"), Some(1));
1311 assert_eq!(status.get_i64("failed"), Some(3));
1312 assert_eq!(status.get_i64("active"), Some(5));
1313 }
1314
1315 #[test]
1316 fn get_i64_absent_slot_returns_none() {
1317 // Absent-slot corner: a fresh `batch/v1::Job` before its
1318 // controller has stamped any counter into `status` (the JSON
1319 // is `{}` or missing the counter key). Every pre-lift consumer
1320 // routed this corner through the `if let Some(...)` guard so
1321 // the `JobStatusView` field kept its `Default::default()` `0`
1322 // seed. A regression that returned `Some(0)` on the absent
1323 // corner would collapse the "not yet reported" ↔ "reported
1324 // zero" distinction the K8s status protocol keeps.
1325 let status = json!({});
1326 assert_eq!(status.get_i64("succeeded"), None);
1327 assert_eq!(status.get_i64("any_missing_key"), None);
1328 }
1329
1330 #[test]
1331 fn get_i64_present_but_non_integer_slot_returns_none() {
1332 // Present-but-non-integer corner: a `Value::String`, a
1333 // `Value::Bool`, a `Value::Object`, or a `Value::Array` at the
1334 // slot ALL fall through to `None` — matches the pre-lift
1335 // `.and_then(|v| v.as_i64())` chain exactly. A regression that
1336 // promoted a `Value::String("1")` to `Some(1)` (adding a
1337 // parse-string fallback) would silently accept a malformed
1338 // status blob whose author stringified a counter.
1339 let status = json!({
1340 "stringy": "1",
1341 "boolean": true,
1342 "object": {},
1343 "array": [],
1344 "null_valued": null,
1345 });
1346 assert_eq!(status.get_i64("stringy"), None);
1347 assert_eq!(status.get_i64("boolean"), None);
1348 assert_eq!(status.get_i64("object"), None);
1349 assert_eq!(status.get_i64("array"), None);
1350 assert_eq!(status.get_i64("null_valued"), None);
1351 }
1352
1353 #[test]
1354 fn get_i64_negative_counter_survives_the_projection() {
1355 // Negative-integer corner: `as_i64` accepts negatives; `as_u64`
1356 // does not. A regression that narrowed the projection to
1357 // `as_u64` under a mistaken "K8s counters are always non-
1358 // negative" refactor would silently drop every negative
1359 // counter a JSON authoring bug could stamp — hiding the bug
1360 // rather than surfacing it as a counter the diagnostic reports
1361 // verbatim.
1362 let status = json!({ "n": -1 });
1363 assert_eq!(status.get_i64("n"), Some(-1));
1364 }
1365
1366 #[test]
1367 fn get_i64_non_object_receiver_returns_none_verbatim() {
1368 // Non-object receiver corner: a caller who reached this
1369 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1370 // handle (a malformed fetch response, an upstream default-value
1371 // fallback) MUST get `None` back rather than a panic or a
1372 // synthesized `Some(default)`. Matches the pre-lift chain's
1373 // behaviour: `Value::get` on a non-object receiver returns
1374 // `None`, `and_then` short-circuits.
1375 assert_eq!(Value::Null.get_i64("any"), None);
1376 assert_eq!(Value::Bool(true).get_i64("any"), None);
1377 assert_eq!(json!([1, 2, 3]).get_i64("any"), None);
1378 assert_eq!(json!("scalar").get_i64("any"), None);
1379 }
1380
1381 #[test]
1382 fn get_i64_matches_pre_lift_hand_authored_chain_shape() {
1383 // Byte-shape parity pin: `<value>.get_i64(<key>)` MUST return
1384 // the SAME `Option<i64>` the pre-lift hand-authored
1385 // `.get(<key>).and_then(|v| v.as_i64())` chain produced.
1386 // Sweeps the six pre-lift-reachable input corners (the three
1387 // "value present" + three "value absent/malformed" arms every
1388 // fetch_job_status callsite reached) so a regression at the
1389 // primitive that broke byte identity with the pre-lift chain at
1390 // ONE corner surfaces here rather than as a per-counter
1391 // divergence at the fetched-Job projection.
1392 let status = json!({
1393 "succeeded": 2,
1394 "failed": 0,
1395 "active": 7,
1396 "stringy": "1",
1397 "null_valued": null,
1398 });
1399 for key in [
1400 "succeeded",
1401 "failed",
1402 "active",
1403 "stringy",
1404 "null_valued",
1405 "missing",
1406 ] {
1407 let via_primitive = status.get_i64(key);
1408 let via_pre_lift = status.get(key).and_then(|v| v.as_i64());
1409 assert_eq!(
1410 via_primitive, via_pre_lift,
1411 "corner `{key}` must round-trip through both shapes",
1412 );
1413 }
1414 }
1415
1416 #[test]
1417 fn get_i64_composes_with_unwrap_or_default_at_default_seed_shape() {
1418 // Downstream composition pin: the canonical caller shape
1419 // post-lift is `<status>.get_i64(<key>).unwrap_or_default()` —
1420 // matches the pre-lift `JobStatusView::default()` seed +
1421 // conditional `if let Some(n)` write pattern. A regression that
1422 // reshaped the return form (an `i64` bare default, a
1423 // `Result<i64, _>` fallible arm) would break this composition.
1424 let status = json!({ "succeeded": 4 });
1425 // Absent slot composes to the type default (0 for i64).
1426 assert_eq!(status.get_i64("missing").unwrap_or_default(), 0_i64);
1427 // Present slot composes to the projected counter.
1428 assert_eq!(status.get_i64("succeeded").unwrap_or_default(), 4_i64);
1429 }
1430
1431 // ─── ValueGetExt::get_str substrate pins ─────────────────────────
1432 //
1433 // Fail-before-pass-after granularity: the `ValueGetExt::get_str`
1434 // trait method did not exist before this commit, so each test below
1435 // fails to compile pre-lift. Post-lift they collectively pin the
1436 // paired READ-shape at ONE substrate owner — a regression that
1437 // narrowed the projection to the wrong variant (accepting
1438 // `Value::Number`-stringified slots via a fallback, or accepting
1439 // `Value::Null` as `Some("")`), swapped the slot lookup to
1440 // `.pointer(<key>)` (losing the direct-child semantics), promoted
1441 // an absent slot to `Some("")` (silently paving over a missing
1442 // required slot), or drifted the receiver-non-object arm from
1443 // `None` (silently synthesising an empty string on a null status
1444 // blob) surfaces HERE rather than as silent operator-facing skew
1445 // across the SEVEN pre-lift consumers (`status::from_json`'s four
1446 // rendered-resource coordinate reads + `ssapply::ready_condition_value`'s
1447 // three K8s Condition slot reads).
1448
1449 #[test]
1450 fn get_str_present_string_slot_returns_the_slice() {
1451 // Primary Ok-arm invariant: a `Value::String(s)` present at the
1452 // slot projects to `Some(s.as_str())`. Sweeps the four
1453 // representative slots the pre-lift `RenderedResourceCoords::
1454 // from_json` consumer walked (`apiVersion`, `kind`,
1455 // `metadata.name`, `metadata.namespace`) so a regression at
1456 // ONE axis surfaces here rather than at the downstream
1457 // typed row's coordinate.
1458 let manifest = json!({
1459 "apiVersion": "helm.toolkit.fluxcd.io/v2",
1460 "kind": "HelmRelease",
1461 "name": "demo-app",
1462 "namespace": "demo",
1463 });
1464 assert_eq!(
1465 manifest.get_str("apiVersion"),
1466 Some("helm.toolkit.fluxcd.io/v2"),
1467 );
1468 assert_eq!(manifest.get_str("kind"), Some("HelmRelease"));
1469 assert_eq!(manifest.get_str("name"), Some("demo-app"));
1470 assert_eq!(manifest.get_str("namespace"), Some("demo"));
1471 }
1472
1473 #[test]
1474 fn get_str_absent_slot_returns_none() {
1475 // Absent-slot corner: a rendered manifest whose author forgot
1476 // the `apiVersion` slot (a common authoring bug) MUST return
1477 // `None` so `RenderedResourceCoords::from_json` fails loud
1478 // rather than silently synthesising an empty apiVersion. A
1479 // regression that returned `Some("")` on the absent corner
1480 // would collapse the "not authored" ↔ "authored empty"
1481 // distinction the fail-loud gate depends on.
1482 let manifest = json!({ "kind": "HelmRelease" });
1483 assert_eq!(manifest.get_str("apiVersion"), None);
1484 assert_eq!(manifest.get_str("any_missing_key"), None);
1485 }
1486
1487 #[test]
1488 fn get_str_present_but_non_string_slot_returns_none() {
1489 // Present-but-non-string corner: a `Value::Number`,
1490 // `Value::Bool`, `Value::Object`, `Value::Array`, or
1491 // `Value::Null` at the slot ALL fall through to `None` —
1492 // matches the pre-lift `.and_then(|v| v.as_str())` chain
1493 // exactly. A regression that stringified a `Value::Number`
1494 // (adding a `to_string()` fallback) would silently accept a
1495 // malformed manifest whose author numeric-typed a
1496 // conventionally-string slot.
1497 let manifest = json!({
1498 "numeric": 1,
1499 "boolean": true,
1500 "object": {},
1501 "array": [],
1502 "null_valued": null,
1503 });
1504 assert_eq!(manifest.get_str("numeric"), None);
1505 assert_eq!(manifest.get_str("boolean"), None);
1506 assert_eq!(manifest.get_str("object"), None);
1507 assert_eq!(manifest.get_str("array"), None);
1508 assert_eq!(manifest.get_str("null_valued"), None);
1509 }
1510
1511 #[test]
1512 fn get_str_empty_string_slot_survives_the_projection() {
1513 // Empty-string corner: a `Value::String("")` present at the
1514 // slot MUST project to `Some("")` — matches the pre-lift
1515 // `.and_then(|v| v.as_str())` chain exactly, keeping the
1516 // "authored empty" arm distinct from the "not authored" arm
1517 // upstream. A regression that promoted `Some("")` to `None`
1518 // under a "reject empty strings" refactor would silently
1519 // collapse the two arms and turn a valid empty `metadata.
1520 // namespace` (a cluster-scoped resource) into a fail-loud
1521 // error at the required-slot gates.
1522 let manifest = json!({ "namespace": "" });
1523 assert_eq!(manifest.get_str("namespace"), Some(""));
1524 }
1525
1526 #[test]
1527 fn get_str_non_object_receiver_returns_none_verbatim() {
1528 // Non-object receiver corner: a caller who reached this
1529 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1530 // handle (a malformed fetch response, an upstream default-value
1531 // fallback, a `serde_json::Value::Null` metadata slot chained
1532 // through `.and_then`) MUST get `None` back rather than a
1533 // panic or a synthesized `Some("")`. Matches the pre-lift
1534 // chain's behaviour: `Value::get` on a non-object receiver
1535 // returns `None`, `and_then` short-circuits.
1536 assert_eq!(Value::Null.get_str("any"), None);
1537 assert_eq!(Value::Bool(true).get_str("any"), None);
1538 assert_eq!(json!([1, 2, 3]).get_str("any"), None);
1539 assert_eq!(json!("scalar").get_str("any"), None);
1540 }
1541
1542 #[test]
1543 fn get_str_matches_pre_lift_hand_authored_chain_shape() {
1544 // Byte-shape parity pin: `<value>.get_str(<key>)` MUST return
1545 // the SAME `Option<&str>` the pre-lift hand-authored
1546 // `.get(<key>).and_then(|v| v.as_str())` chain produced.
1547 // Sweeps every pre-lift-reachable input corner (three
1548 // "value present" + three "value absent/malformed" arms every
1549 // status.rs / ssapply.rs callsite reached) so a regression at
1550 // the primitive that broke byte identity with the pre-lift
1551 // chain at ONE corner surfaces here rather than as a
1552 // per-slot divergence downstream.
1553 let manifest = json!({
1554 "apiVersion": "v1",
1555 "kind": "ConfigMap",
1556 "type": "Ready",
1557 "numeric": 1,
1558 "null_valued": null,
1559 });
1560 for key in [
1561 "apiVersion",
1562 "kind",
1563 "type",
1564 "numeric",
1565 "null_valued",
1566 "missing",
1567 ] {
1568 let via_primitive = manifest.get_str(key);
1569 let via_pre_lift = manifest.get(key).and_then(|v| v.as_str());
1570 assert_eq!(
1571 via_primitive, via_pre_lift,
1572 "corner `{key}` must round-trip through both shapes",
1573 );
1574 }
1575 }
1576
1577 #[test]
1578 fn get_str_composes_with_ok_or_else_at_from_json_shape() {
1579 // Downstream composition pin: the canonical caller shape at
1580 // `RenderedResourceCoords::from_json` is
1581 // `<manifest>.get_str(<key>).ok_or_else(|| anyhow!("rendered
1582 // resource missing X"))?.to_string()`. A regression that
1583 // reshaped the return form (an `&str` bare default, a
1584 // `Result<&str, _>` fallible arm) would break this
1585 // composition. Additionally sweeps the peer
1586 // `.map(String::from)` / `.map(str::to_string)` optional-slot
1587 // arm the `namespace` slot uses.
1588 let manifest = json!({ "apiVersion": "v1" });
1589 let ok_arm: String = manifest
1590 .get_str("apiVersion")
1591 .ok_or_else(|| anyhow::anyhow!("missing"))
1592 .unwrap()
1593 .to_string();
1594 assert_eq!(ok_arm, "v1");
1595 let err_arm = manifest
1596 .get_str("kind")
1597 .ok_or_else(|| anyhow::anyhow!("rendered resource missing kind"))
1598 .unwrap_err();
1599 assert_eq!(format!("{err_arm}"), "rendered resource missing kind");
1600 let opt_present: Option<String> = manifest.get_str("apiVersion").map(str::to_string);
1601 assert_eq!(opt_present.as_deref(), Some("v1"));
1602 let opt_absent: Option<String> = manifest.get_str("kind").map(String::from);
1603 assert!(opt_absent.is_none());
1604 }
1605
1606 #[test]
1607 fn get_str_return_lifetime_borrows_receiver_not_owned() {
1608 // Return-lifetime pin: the `&str` MUST borrow the receiver's
1609 // buffer rather than a fresh owned `String`. A regression that
1610 // reshaped the return to `Option<String>` (adding a
1611 // `to_string()` inside the primitive) would inflate every
1612 // callsite's allocation count and break `metadata.and_then(|m|
1613 // m.get_str("name"))`'s per-lookup zero-alloc guarantee. Bind
1614 // the invariant structurally: the borrow reaches back through
1615 // the receiver.
1616 let manifest = json!({ "apiVersion": "helm.toolkit.fluxcd.io/v2" });
1617 let s: &str = manifest.get_str("apiVersion").unwrap();
1618 let raw: &str = manifest.get("apiVersion").and_then(|v| v.as_str()).unwrap();
1619 assert!(std::ptr::eq(s.as_ptr(), raw.as_ptr()));
1620 }
1621
1622 #[test]
1623 fn get_str_axis_family_reaches_i64_and_str_through_one_trait_import() {
1624 // Axis-family pin: a caller who imports `ValueGetExt` reaches
1625 // BOTH the string axis (`get_str`) and the integer axis
1626 // (`get_i64`) through the SAME trait handle. A regression that
1627 // opened a peer `ValueGetStrExt` (or a peer trait per axis)
1628 // would break this — the caller would have to import each
1629 // trait separately and a partial import would silently miss
1630 // one axis at method-resolution time.
1631 //
1632 // Structurally: a bound `T: ValueGetExt` reaches both methods.
1633 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>) {
1634 (t.get_i64("n"), t.get_str("s"))
1635 }
1636 let mixed = json!({ "n": 7, "s": "hello" });
1637 let (n, s) = probe(&mixed);
1638 assert_eq!(n, Some(7));
1639 assert_eq!(s, Some("hello"));
1640 }
1641
1642 // ─── ValueGetExt::get_array substrate pins ───────────────────────
1643 //
1644 // Fail-before-pass-after granularity: the `ValueGetExt::get_array`
1645 // trait method did not exist before this commit, so each test below
1646 // fails to compile pre-lift. Post-lift they collectively pin the
1647 // paired READ-shape at ONE substrate owner — a regression that
1648 // narrowed the projection to the wrong variant (accepting an
1649 // object slot via a `.values().collect()` synthesis, promoting an
1650 // absent slot to `Some(&Vec::new())`), swapped the slot lookup to
1651 // `.pointer(<key>)` (losing the direct-child semantics), or
1652 // drifted the receiver-non-object arm from `None` (silently
1653 // synthesising an empty array on a null status blob) surfaces
1654 // HERE rather than as silent operator-facing skew across the two
1655 // pre-lift consumers (`ssapply::ready_condition_value`'s
1656 // `status.conditions` walker + `probe::count_jwks_keys`'s `keys`
1657 // counter).
1658
1659 #[test]
1660 fn get_array_present_array_slot_returns_the_slice() {
1661 // Primary Ok-arm invariant: a `Value::Array` present at the
1662 // slot projects to `Some(&Vec::new())`-shaped borrow. Sweeps
1663 // the two representative shapes the pre-lift consumers walked
1664 // (a K8s `status.conditions` array of Condition objects on the
1665 // reconciler side; a JWKS `keys` array of key objects on the
1666 // probe side).
1667 let status = json!({
1668 "conditions": [
1669 { "type": "Ready", "status": "True" },
1670 { "type": "Progressing", "status": "False" },
1671 ],
1672 });
1673 let via = status.get_array("conditions").expect("Value::Array");
1674 assert_eq!(via.len(), 2);
1675 assert_eq!(via[0]["type"], "Ready");
1676
1677 let jwks = json!({
1678 "keys": [
1679 { "kty": "RSA", "kid": "1" },
1680 { "kty": "RSA", "kid": "2" },
1681 { "kty": "EC", "kid": "3" },
1682 ],
1683 });
1684 assert_eq!(
1685 jwks.get_array("keys").map(Vec::len),
1686 Some(3),
1687 "probe count_jwks_keys composition must reach the same tail as pre-lift",
1688 );
1689 }
1690
1691 #[test]
1692 fn get_array_absent_slot_returns_none() {
1693 // Absent-slot corner: a fresh K8s status blob whose controller
1694 // has not stamped `conditions` yet (the `data.get("status")`
1695 // walker yields an object without the slot) MUST return
1696 // `None` so the caller's `let Some(...) = ... else { return
1697 // ReadyState::Unknown }` short-circuit fires. A regression that
1698 // returned `Some(&Vec::new())` on the absent corner would
1699 // silently drive the caller into an empty for-loop and skip
1700 // the fail-safe.
1701 let status = json!({});
1702 assert_eq!(status.get_array("conditions"), None);
1703 assert_eq!(status.get_array("any_missing_key"), None);
1704 }
1705
1706 #[test]
1707 fn get_array_present_but_non_array_slot_returns_none() {
1708 // Present-but-non-array corner: a `Value::String`,
1709 // `Value::Number`, `Value::Bool`, `Value::Object`, or
1710 // `Value::Null` at the slot ALL fall through to `None` —
1711 // matches the pre-lift `.and_then(|v| v.as_array())` chain
1712 // exactly. A regression that wrapped a scalar in a single-
1713 // element array under a "tolerant" refactor would silently
1714 // accept a malformed status blob whose author collapsed the
1715 // conditions array to a single scalar.
1716 let status = json!({
1717 "stringy": "ready",
1718 "numeric": 1,
1719 "boolean": true,
1720 "object": { "nested": true },
1721 "null_valued": null,
1722 });
1723 assert_eq!(status.get_array("stringy"), None);
1724 assert_eq!(status.get_array("numeric"), None);
1725 assert_eq!(status.get_array("boolean"), None);
1726 assert_eq!(status.get_array("object"), None);
1727 assert_eq!(status.get_array("null_valued"), None);
1728 }
1729
1730 #[test]
1731 fn get_array_empty_array_slot_survives_the_projection() {
1732 // Empty-array corner: a `Value::Array` with zero elements at
1733 // the slot MUST project to `Some(&Vec::new())` — matches the
1734 // pre-lift chain exactly, keeping the "authored empty" arm
1735 // distinct from the "not authored" arm upstream. The probe
1736 // consumer's `.map(|xs| xs.len() as u64).unwrap_or(0)` tail
1737 // depends on this: an authored-empty JWKS array reports 0
1738 // keys, distinct from a JWKS response missing the `keys` slot
1739 // altogether (which the caller could later choose to log
1740 // differently).
1741 let jwks = json!({ "keys": [] });
1742 let arr = jwks.get_array("keys").expect("Value::Array");
1743 assert!(arr.is_empty());
1744 assert_eq!(jwks.get_array("keys").map(Vec::len), Some(0));
1745 }
1746
1747 #[test]
1748 fn get_array_non_object_receiver_returns_none_verbatim() {
1749 // Non-object receiver corner: a caller who reached this
1750 // primitive on a `Value::Null` / `Value::Bool` / `Value::Array`
1751 // handle (a malformed fetch response, an upstream default-value
1752 // fallback, a `serde_json::Value::Null` intermediate chained
1753 // through `.and_then`) MUST get `None` back rather than a
1754 // panic or a synthesized `Some(&Vec::new())`. Matches the
1755 // pre-lift chain's behaviour: `Value::get` on a non-object
1756 // receiver returns `None`, `and_then` short-circuits.
1757 assert_eq!(Value::Null.get_array("any"), None);
1758 assert_eq!(Value::Bool(true).get_array("any"), None);
1759 assert_eq!(json!([1, 2, 3]).get_array("any"), None);
1760 assert_eq!(json!("scalar").get_array("any"), None);
1761 }
1762
1763 #[test]
1764 fn get_array_matches_pre_lift_hand_authored_chain_shape() {
1765 // Byte-shape parity pin: `<value>.get_array(<key>)` MUST return
1766 // the SAME `Option<&Vec<Value>>` the pre-lift hand-authored
1767 // `.get(<key>).and_then(|v| v.as_array())` chain produced.
1768 // Sweeps every pre-lift-reachable input corner (three
1769 // "value present" + three "value absent/malformed" arms
1770 // covering the two pre-lift consumers) so a regression at the
1771 // primitive that broke byte identity with the pre-lift chain
1772 // at ONE corner surfaces here rather than as a per-slot
1773 // divergence downstream.
1774 let manifest = json!({
1775 "conditions": [{ "type": "Ready" }],
1776 "keys": [{ "kid": "1" }, { "kid": "2" }],
1777 "empty": [],
1778 "stringy": "not-an-array",
1779 "null_valued": null,
1780 });
1781 for key in [
1782 "conditions",
1783 "keys",
1784 "empty",
1785 "stringy",
1786 "null_valued",
1787 "missing",
1788 ] {
1789 let via_primitive = manifest.get_array(key);
1790 let via_pre_lift = manifest.get(key).and_then(|v| v.as_array());
1791 assert_eq!(
1792 via_primitive, via_pre_lift,
1793 "corner `{key}` must round-trip through both shapes",
1794 );
1795 }
1796 }
1797
1798 #[test]
1799 fn get_array_composes_with_len_map_at_probe_count_jwks_keys_shape() {
1800 // Downstream composition pin: the canonical caller shape at
1801 // `probe::count_jwks_keys` is `<body_val>.get_array(<key>).
1802 // map(|xs| xs.len() as u64).unwrap_or(0)` — matches the
1803 // pre-lift `.get(<key>).cloned().and_then(|k| k.as_array().
1804 // map(|xs| xs.len() as u64)).unwrap_or(0)` chain shed of its
1805 // pre-lift `.cloned()` allocation. A regression that reshaped
1806 // the return form (an `Option<Vec<Value>>` owned, a
1807 // `Result<...>` fallible arm) would break this composition
1808 // AND reintroduce the eliminated allocation.
1809 let jwks = json!({ "keys": [{ "kid": "1" }, { "kid": "2" }, { "kid": "3" }] });
1810 let n: u64 = jwks
1811 .get_array("keys")
1812 .map(|xs| xs.len() as u64)
1813 .unwrap_or(0);
1814 assert_eq!(n, 3);
1815 // Missing slot composes to 0 through the same unwrap_or arm.
1816 let empty = json!({});
1817 let z: u64 = empty
1818 .get_array("keys")
1819 .map(|xs| xs.len() as u64)
1820 .unwrap_or(0);
1821 assert_eq!(z, 0);
1822 }
1823
1824 #[test]
1825 fn get_array_composes_with_let_else_short_circuit_at_ready_condition_shape() {
1826 // Downstream composition pin: the canonical caller shape at
1827 // `ssapply::ready_condition_value` is `let Some(conditions) =
1828 // <data>.get("status").and_then(|s| s.get_array("conditions"))
1829 // else { return ReadyState::Unknown; }` — the walker rides
1830 // the `get_array` primitive on the tail of a nested walk. A
1831 // regression that changed the return to `Option<Vec<Value>>`
1832 // owned would break the `for c in conditions` borrow-iterate
1833 // pattern downstream (each `c` borrows through the receiver).
1834 let data = json!({
1835 "status": {
1836 "conditions": [
1837 { "type": "Ready", "status": "True" },
1838 { "type": "Progressing", "status": "False" },
1839 ],
1840 },
1841 });
1842 let conditions = data
1843 .get("status")
1844 .and_then(|s| s.get_array("conditions"))
1845 .expect("nested walk resolves");
1846 assert_eq!(conditions.len(), 2);
1847 // Verifies borrow-through-receiver: iterate without cloning.
1848 let types: Vec<&str> = conditions
1849 .iter()
1850 .filter_map(|c| c.get_str("type"))
1851 .collect();
1852 assert_eq!(types, vec!["Ready", "Progressing"]);
1853 }
1854
1855 #[test]
1856 fn get_array_return_lifetime_borrows_receiver_not_owned() {
1857 // Return-lifetime pin: the `&Vec<Value>` MUST borrow the
1858 // receiver's buffer rather than a fresh owned `Vec`. A
1859 // regression that reshaped the return to `Option<Vec<Value>>`
1860 // (adding a `.clone()` inside the primitive) would inflate
1861 // every callsite's allocation count and — for the
1862 // ssapply.rs caller — reintroduce a per-reconcile clone of
1863 // every K8s Condition on every DynamicObject readiness probe.
1864 // Bind the invariant structurally: the borrow reaches back
1865 // through the receiver.
1866 let manifest = json!({ "keys": [{ "kid": "1" }, { "kid": "2" }] });
1867 let via_primitive: &Vec<Value> = manifest.get_array("keys").unwrap();
1868 let via_raw: &Vec<Value> = manifest.get("keys").and_then(|v| v.as_array()).unwrap();
1869 assert!(std::ptr::eq(via_primitive.as_ptr(), via_raw.as_ptr()));
1870 }
1871
1872 #[test]
1873 fn get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import() {
1874 // Axis-family pin: a caller who imports `ValueGetExt` reaches
1875 // the integer axis (`get_i64`), the string axis (`get_str`),
1876 // AND the array axis (`get_array`) through the SAME trait
1877 // handle. A regression that opened a peer `ValueGetArrayExt`
1878 // (or a peer trait per axis) would break this — the caller
1879 // would have to import each trait separately and a partial
1880 // import would silently miss one axis at method-resolution
1881 // time.
1882 //
1883 // Structurally: a bound `T: ValueGetExt` reaches all three
1884 // methods. This test extends the pre-existing
1885 // `get_str_axis_family_reaches_i64_and_str_through_one_trait_import`
1886 // sibling to cover the new axis; either drops means the
1887 // axis-family invariant no longer holds.
1888 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>) {
1889 (t.get_i64("n"), t.get_str("s"), t.get_array("a"))
1890 }
1891 let mixed = json!({ "n": 7, "s": "hello", "a": [1, 2, 3] });
1892 let (n, s, a) = probe(&mixed);
1893 assert_eq!(n, Some(7));
1894 assert_eq!(s, Some("hello"));
1895 assert_eq!(a.map(Vec::len), Some(3));
1896 }
1897
1898 // ─── ValueGetExt::get_bool substrate pins ───────────────────────
1899 //
1900 // Fail-before-pass-after granularity: the `ValueGetExt::get_bool`
1901 // trait method did not exist before this commit, so each test
1902 // below fails to compile pre-lift (a bare `Value` receiver has no
1903 // `.get_bool(<key>)` inherent method — only the upstream
1904 // `.get(<key>).and_then(|v| v.as_bool())` chain). Post-lift they
1905 // collectively pin the boolean-axis projection at ONE substrate
1906 // owner — a regression that drifted the projection axis
1907 // (`as_bool` → `as_str` narrowing the accepted variant, `.get()`
1908 // → `.pointer()` losing the direct-child semantics), promoted the
1909 // absent-slot corner to a synthesis (`None → Ok(false)`
1910 // fallthrough that would silently swallow a mistyped slot), or
1911 // narrowed the `Option<bool>` return to a bare `bool` (dropping
1912 // the "absent vs false" distinction) surfaces HERE rather than
1913 // as silent operator-facing skew across every downstream K8s
1914 // boolean-flag consumer (a `controller` / `blockOwnerDeletion`
1915 // OwnerReference gate, a `spec.suspended` SIGSTOP toggle read,
1916 // an `identity.name_override` phase-status probe, a
1917 // `hostNetwork` pod-spec gate).
1918
1919 #[test]
1920 fn get_bool_present_bool_slot_returns_the_flag() {
1921 // Ok-arm invariant on both polarities: a `Value::Bool(true)`
1922 // slot projects to `Some(true)` and a `Value::Bool(false)`
1923 // slot projects to `Some(false)`. A regression that only
1924 // returned `Some(true)` on the truthy arm and folded the
1925 // falsy arm to `None` (a "presence + truth" conflation) would
1926 // silently gate every downstream `spec.suspended = false`
1927 // resume-arm consumer as "flag absent" and mis-fire the
1928 // heartbeat pause release.
1929 let obj = json!({ "on": true, "off": false });
1930 assert_eq!(obj.get_bool("on"), Some(true));
1931 assert_eq!(obj.get_bool("off"), Some(false));
1932 }
1933
1934 #[test]
1935 fn get_bool_absent_slot_returns_none() {
1936 // Absent-slot arm: a missing key returns `None` verbatim,
1937 // matching the composed inherent chain's semantics. A
1938 // regression that promoted the absent corner to `Some(false)`
1939 // (folding "the operator didn't set the flag" into "the
1940 // operator set the flag false") would silently invert the
1941 // meaning at every consumer whose `unwrap_or(true)` fallback
1942 // expected the absent corner to reach the true arm.
1943 let obj = json!({ "on": true });
1944 assert!(obj.get_bool("missing").is_none());
1945 }
1946
1947 #[test]
1948 fn get_bool_wrong_variant_returns_none() {
1949 // Wrong-variant arm: a slot present but non-boolean
1950 // (`Value::String`, `Value::Number`, `Value::Null`,
1951 // `Value::Array`, `Value::Object`) projects to `None` — the
1952 // primitive does NOT coerce a `Value::String("true")` /
1953 // `Value::Number(1)` into a boolean, matching the inherent
1954 // `Value::as_bool` semantics. A regression that added truthy
1955 // coercion would silently promote a K8s wire-form drift (a
1956 // stringified boolean) into an accepted flag at every
1957 // consumer, which is never the intended semantic at any
1958 // downstream boolean-flag reader — a K8s API server returning
1959 // a stringified boolean signals wire-form drift the consumer
1960 // should notice.
1961 let obj = json!({
1962 "stringy": "true",
1963 "numeric": 1,
1964 "null_valued": null,
1965 "arrayed": [true],
1966 "nested": { "on": true },
1967 });
1968 assert!(obj.get_bool("stringy").is_none());
1969 assert!(obj.get_bool("numeric").is_none());
1970 assert!(obj.get_bool("null_valued").is_none());
1971 assert!(obj.get_bool("arrayed").is_none());
1972 assert!(obj.get_bool("nested").is_none());
1973 }
1974
1975 #[test]
1976 fn get_bool_non_object_receiver_returns_none() {
1977 // Non-object receiver arm: a `Value::String` / `Value::Null`
1978 // / `Value::Array` / `Value::Number` / `Value::Bool` receiver
1979 // returns `None` verbatim via the inherent `Value::get`'s
1980 // own non-object-arm behaviour — the primitive doesn't
1981 // special-case the case where the caller's status blob is
1982 // malformed at the receiver level. Matches the sibling axis
1983 // methods' `get_i64` / `get_str` / `get_array` behaviour on
1984 // the same corner.
1985 assert!(Value::Null.get_bool("k").is_none());
1986 assert!(Value::String("hi".into()).get_bool("k").is_none());
1987 assert!(json!([true, false]).get_bool("k").is_none());
1988 assert!(json!(1).get_bool("k").is_none());
1989 assert!(json!(true).get_bool("k").is_none());
1990 }
1991
1992 #[test]
1993 fn get_bool_matches_pre_lift_hand_authored_chain_bytewise() {
1994 // Byte-shape parity pin: `<value>.get_bool(<key>)` MUST return
1995 // the SAME `Option<bool>` the pre-lift `.get(<key>).and_then(
1996 // Value::as_bool)` two-link chain produced. Sweeps every
1997 // reachable corner (both polarities present, wrong-variant,
1998 // absent) so a regression at the primitive that broke byte
1999 // identity with the pre-lift chain at ONE corner surfaces
2000 // here rather than as a subtle per-slot divergence at
2001 // downstream K8s-flag readers.
2002 let v = json!({
2003 "on": true,
2004 "off": false,
2005 "stringy": "true",
2006 "numeric": 1,
2007 "null_valued": null,
2008 });
2009 for key in ["on", "off", "stringy", "numeric", "null_valued", "missing"] {
2010 let via_primitive = v.get_bool(key);
2011 let via_pre_lift = v.get(key).and_then(Value::as_bool);
2012 assert_eq!(
2013 via_primitive, via_pre_lift,
2014 "corner `{key}` on Value receiver must round-trip through both shapes",
2015 );
2016 }
2017 }
2018
2019 #[test]
2020 fn get_bool_axis_family_reaches_i64_str_array_and_bool_through_one_trait_import() {
2021 // Axis-family completion pin: a caller who imports
2022 // `ValueGetExt` reaches the integer axis (`get_i64`), the
2023 // string axis (`get_str`), the array axis (`get_array`), AND
2024 // the boolean axis (`get_bool`) through the SAME trait
2025 // handle. Structurally: a bound `T: ValueGetExt` reaches all
2026 // four methods. Extends the pre-existing
2027 // `get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import`
2028 // sibling to cover the fourth axis; a regression that
2029 // opened a peer `ValueGetBoolExt` (or split the trait into
2030 // per-axis peers) would fail this bound at compile time
2031 // rather than surface as a silent "one axis is missing on
2032 // one receiver" drift at every downstream consumer.
2033 fn probe<T: ValueGetExt>(
2034 t: &T,
2035 ) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>, Option<bool>) {
2036 (
2037 t.get_i64("n"),
2038 t.get_str("s"),
2039 t.get_array("a"),
2040 t.get_bool("b"),
2041 )
2042 }
2043 let mixed = json!({ "n": 7, "s": "hello", "a": [1, 2, 3], "b": true });
2044 let (n, s, a, b) = probe(&mixed);
2045 assert_eq!(n, Some(7));
2046 assert_eq!(s, Some("hello"));
2047 assert_eq!(a.map(Vec::len), Some(3));
2048 assert_eq!(b, Some(true));
2049 }
2050
2051 #[test]
2052 fn map_receiver_get_bool_matches_pre_lift_hand_authored_chain_bytewise() {
2053 // Receiver-parity pin on the boolean axis: an `&Map<String,
2054 // Value>` handle reaches `.get_bool(<key>)` and returns the
2055 // SAME `Option<bool>` the `Value` receiver's arm produces for
2056 // an equivalent `Value::Object(m)` walk. Sibling to
2057 // `map_receiver_get_i64_matches_pre_lift_hand_authored_chain_bytewise`
2058 // on the integer axis; both close the "widening preserves
2059 // semantics" invariant across all four axes of the family.
2060 let obj: Map<String, Value> = json!({
2061 "on": true,
2062 "off": false,
2063 "stringy": "true",
2064 "numeric": 1,
2065 "null_valued": null,
2066 })
2067 .as_object()
2068 .unwrap()
2069 .clone();
2070 for key in ["on", "off", "stringy", "numeric", "null_valued", "missing"] {
2071 let via_primitive = obj.get_bool(key);
2072 let via_pre_lift = obj.get(key).and_then(Value::as_bool);
2073 assert_eq!(
2074 via_primitive, via_pre_lift,
2075 "corner `{key}` on Map receiver's boolean axis must round-trip through both shapes",
2076 );
2077 }
2078 }
2079
2080 #[test]
2081 fn map_receiver_get_bool_matches_value_object_arm_bytewise() {
2082 // Cross-receiver coherence pin on the boolean axis: an
2083 // `&Map<String, Value>` receiver's `.get_bool(<key>)` MUST
2084 // return the SAME `Option<bool>` walking the equivalent
2085 // `Value::Object(m)` through the pre-existing `Value` impl
2086 // would. Sibling to
2087 // `map_receiver_get_str_matches_value_object_arm_bytewise` on
2088 // the string axis — a regression that specialised the Map
2089 // arm's boolean projection at ONE receiver but not the other
2090 // would silently split the two receiver shapes' behaviour
2091 // and break the "widening preserves semantics" invariant on
2092 // the boolean axis specifically.
2093 let v: Value = json!({
2094 "controller": true,
2095 "blockOwnerDeletion": true,
2096 "suspended": false,
2097 "nested": { "on": true },
2098 });
2099 let m: &Map<String, Value> = v.as_object().unwrap();
2100 for key in [
2101 "controller",
2102 "blockOwnerDeletion",
2103 "suspended",
2104 "nested",
2105 "missing",
2106 ] {
2107 assert_eq!(
2108 <Map<String, Value> as ValueGetExt>::get_bool(m, key),
2109 <Value as ValueGetExt>::get_bool(&v, key),
2110 "receiver-shape parity: `{key}` must project identically through both impls",
2111 );
2112 }
2113 }
2114
2115 #[test]
2116 fn map_receiver_axis_family_reaches_all_four_axes_through_one_trait_import() {
2117 // Axis-family completion pin on the Map receiver: a generic
2118 // `T: ValueGetExt` bound reaches ALL FOUR axes on the Map
2119 // arm — the SAME structural invariant the sibling
2120 // `get_bool_axis_family_reaches_i64_str_array_and_bool_through_one_trait_import`
2121 // pins for the `Value` receiver. Walking the SAME `probe`-
2122 // style generic through the Map arm here means a regression
2123 // that split the trait into per-axis peers would break the
2124 // invariant on both receiver shapes simultaneously.
2125 fn probe<T: ValueGetExt>(
2126 t: &T,
2127 ) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>, Option<bool>) {
2128 (
2129 t.get_i64("n"),
2130 t.get_str("s"),
2131 t.get_array("a"),
2132 t.get_bool("b"),
2133 )
2134 }
2135 let m: Map<String, Value> = json!({ "n": 7, "s": "hello", "a": [1, 2, 3], "b": true })
2136 .as_object()
2137 .unwrap()
2138 .clone();
2139 let (n, s, a, b) = probe(&m);
2140 assert_eq!(n, Some(7));
2141 assert_eq!(s, Some("hello"));
2142 assert_eq!(a.map(Vec::len), Some(3));
2143 assert_eq!(b, Some(true));
2144 }
2145
2146 // ─── ValueGetExt receiver-shape widening — Map impl pins ─────────
2147 //
2148 // Fail-before-pass-after granularity: `impl ValueGetExt for
2149 // Map<String, Value>` did not exist before this commit, so each
2150 // test below fails to compile pre-lift (a bare `Map<String, Value>`
2151 // receiver has no `.get_str(<key>)` inherent method — only the
2152 // upstream `.get(<key>).and_then(Value::as_str)` chain — so the
2153 // callsite fails method resolution). Post-lift they collectively
2154 // pin the widening at ONE substrate owner — a regression that
2155 // dropped the `Map` impl and re-forced every `&Map` receiver into
2156 // a `Value::Object(m.clone())` rewrap detour would surface HERE
2157 // rather than as silent per-emit skew across the 30
2158 // `Map`-receiver pre-lift consumers in `tatara-reconciler::
2159 // {patch,ssapply}` tests.
2160
2161 #[test]
2162 fn map_receiver_reaches_str_i64_and_array_axes_through_the_same_trait() {
2163 // Receiver-parity pin: an `&Map<String, Value>` handle reaches
2164 // the SAME three axes (`get_str`, `get_i64`, `get_array`) the
2165 // `&Value` receiver already exposes. A regression that
2166 // implemented only one axis on the Map arm (a copy-paste
2167 // omission at the impl block) would surface here as one of the
2168 // three assertions failing to compile / returning `None`.
2169 let obj: Map<String, Value> = json!({
2170 "s": "hello",
2171 "n": 42,
2172 "a": [1, 2, 3],
2173 })
2174 .as_object()
2175 .unwrap()
2176 .clone();
2177 assert_eq!(obj.get_str("s"), Some("hello"));
2178 assert_eq!(obj.get_i64("n"), Some(42));
2179 assert_eq!(obj.get_array("a").map(Vec::len), Some(3));
2180 }
2181
2182 #[test]
2183 fn map_receiver_get_str_matches_pre_lift_hand_authored_chain_bytewise() {
2184 // Byte-shape parity pin: `<map>.get_str(<key>)` on a
2185 // `&Map<String, Value>` MUST return the SAME `Option<&str>` the
2186 // pre-lift `.get(<key>).and_then(Value::as_str)` chain
2187 // produced. Sweeps every pre-lift-reachable corner (present
2188 // string, present non-string, absent) so a regression at the
2189 // Map impl that broke byte identity with the pre-lift chain at
2190 // ONE corner surfaces here rather than as a per-slot divergence
2191 // at every `patch::phase_status_*` / `ssapply::ownership_*` pin.
2192 let obj: Map<String, Value> = json!({
2193 "phase": "Running",
2194 "phaseSince": "2026-01-01T00:00:00Z",
2195 "message": "",
2196 "numeric": 7,
2197 "null_valued": null,
2198 })
2199 .as_object()
2200 .unwrap()
2201 .clone();
2202 for key in [
2203 "phase",
2204 "phaseSince",
2205 "message",
2206 "numeric",
2207 "null_valued",
2208 "missing",
2209 ] {
2210 let via_primitive = obj.get_str(key);
2211 let via_pre_lift = obj.get(key).and_then(Value::as_str);
2212 assert_eq!(
2213 via_primitive, via_pre_lift,
2214 "corner `{key}` on Map receiver must round-trip through both shapes",
2215 );
2216 }
2217 }
2218
2219 #[test]
2220 fn map_receiver_get_array_matches_pre_lift_hand_authored_chain_bytewise() {
2221 // Sibling to the `get_str` byte-parity pin on the array axis
2222 // — sweeps present-array / present-non-array / absent so a
2223 // regression at the Map impl's `get_array` arm surfaces here
2224 // rather than as silent drift at
2225 // `patch::finalizers_metadata_patch_wraps_list_in_two_slot_metadata_body`
2226 // and its peers whose `metadata.get("finalizers").and_then(
2227 // Value::as_array)` chain lifts through this substrate.
2228 let obj: Map<String, Value> = json!({
2229 "finalizers": ["tatara.pleme.io/process-finalizer", "other.io/finalizer"],
2230 "fluxResources": [],
2231 "stringy": "not-array",
2232 })
2233 .as_object()
2234 .unwrap()
2235 .clone();
2236 for key in ["finalizers", "fluxResources", "stringy", "missing"] {
2237 let via_primitive = obj.get_array(key);
2238 let via_pre_lift = obj.get(key).and_then(Value::as_array);
2239 assert_eq!(
2240 via_primitive, via_pre_lift,
2241 "corner `{key}` on Map receiver's array axis must round-trip through both shapes",
2242 );
2243 }
2244 }
2245
2246 #[test]
2247 fn map_receiver_get_i64_matches_pre_lift_hand_authored_chain_bytewise() {
2248 // Sibling to the `get_str` / `get_array` byte-parity pins on
2249 // the integer axis — closes the third axis of the family and
2250 // pins that a Map-receiver caller reaching this arm gets the
2251 // SAME `Option<i64>` the pre-lift chain produced.
2252 let obj: Map<String, Value> = json!({
2253 "succeeded": 2,
2254 "failed": 0,
2255 "active": 5,
2256 "stringy": "1",
2257 "null_valued": null,
2258 })
2259 .as_object()
2260 .unwrap()
2261 .clone();
2262 for key in [
2263 "succeeded",
2264 "failed",
2265 "active",
2266 "stringy",
2267 "null_valued",
2268 "missing",
2269 ] {
2270 let via_primitive = obj.get_i64(key);
2271 let via_pre_lift = obj.get(key).and_then(Value::as_i64);
2272 assert_eq!(
2273 via_primitive, via_pre_lift,
2274 "corner `{key}` on Map receiver's integer axis must round-trip through both shapes",
2275 );
2276 }
2277 }
2278
2279 #[test]
2280 fn map_receiver_get_str_matches_value_object_arm_bytewise() {
2281 // Cross-receiver coherence pin: an `&Map<String, Value>`
2282 // receiver's `.get_str(<key>)` MUST return the SAME
2283 // `Option<&str>` that walking the equivalent `Value::Object(m)`
2284 // through the pre-existing `Value` impl would. A regression
2285 // that specialised the Map arm (a slot-name-normalisation
2286 // pass, a per-fleet trim) at ONE receiver but not the other
2287 // would silently split the two receiver shapes' behaviour and
2288 // break the "widening preserves semantics" invariant.
2289 let v: Value = json!({
2290 "apiVersion": "v1",
2291 "kind": "ConfigMap",
2292 "phase": "Running",
2293 });
2294 let m: &Map<String, Value> = v.as_object().unwrap();
2295 for key in ["apiVersion", "kind", "phase", "missing"] {
2296 assert_eq!(
2297 <Map<String, Value> as ValueGetExt>::get_str(m, key),
2298 <Value as ValueGetExt>::get_str(&v, key),
2299 "receiver-shape parity: `{key}` must project identically through both impls",
2300 );
2301 }
2302 }
2303
2304 #[test]
2305 fn map_receiver_axis_family_reaches_all_three_axes_through_one_trait_import() {
2306 // Axis-family + receiver-shape pin combined: a generic
2307 // `T: ValueGetExt` bound reaches ALL THREE axes on the Map
2308 // receiver — the SAME structural invariant the pre-existing
2309 // `get_array_axis_family_reaches_...` sibling pins for the
2310 // `Value` receiver. This test walks the SAME `probe`-style
2311 // generic through the Map arm, so a regression that split the
2312 // trait into per-axis peers would break the invariant on both
2313 // receiver shapes simultaneously.
2314 fn probe<T: ValueGetExt>(t: &T) -> (Option<i64>, Option<&str>, Option<&Vec<Value>>) {
2315 (t.get_i64("n"), t.get_str("s"), t.get_array("a"))
2316 }
2317 let m: Map<String, Value> = json!({ "n": 7, "s": "hello", "a": [1, 2, 3] })
2318 .as_object()
2319 .unwrap()
2320 .clone();
2321 let (n, s, a) = probe(&m);
2322 assert_eq!(n, Some(7));
2323 assert_eq!(s, Some("hello"));
2324 assert_eq!(a.map(Vec::len), Some(3));
2325 }
2326}