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//!
21//! Pre-lift the shape was hand-authored at THREE adjacent private
22//! helpers in `tatara-reconciler::ssapply` past the ★★ PRIME-DIRECTIVE
23//! ≥ 2 duplication threshold:
24//!
25//! * `metadata_object_mut(resource)` — the root-guard step
26//! (`resource.as_object_mut().ok_or_else(|| anyhow!("resource is not
27//! an object"))?`) that opens the SSA-time
28//! `resource → &mut metadata` walk shared by `inject_owner_reference`
29//! + `inject_annotations`.
30//! * `metadata_object_mut(resource)` — the metadata-slot type-check
31//! step (`metadata.as_object_mut().ok_or_else(|| anyhow!("metadata
32//! is not an object"))?`) that closes the same walk — a resource
33//! whose author mistyped the `metadata` slot as an array / string
34//! surfaces as an error rather than as a silent
35//! `.as_object_mut() → None → skip` no-op.
36//! * `inject_annotations(resource, process)` — the annotations-slot
37//! type-check step (`annot.as_object_mut().ok_or_else(|| anyhow!(
38//! "annotations is not an object"))?`) that opens the SSA-time
39//! `metadata → &mut annotations` walk before the ownership tag +
40//! observed-* primitive family drops its keys into the map.
41//!
42//! All three restated the SAME 2-line shape verbatim: `.as_object_mut()`
43//! on a `serde_json::Value` handle already known to be non-null, then
44//! `.ok_or_else(|| anyhow!("<slot-name> is not an object"))` wrap
45//! whose slot name matched the walk step's semantic role (`"resource"`
46//! / `"metadata"` / `"annotations"`). THREE byte-for-byte identical
47//! guard blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
48//! differing only in the `&'static str` slot name each callsite
49//! stamped.
50//!
51//! Post-lift each callsite reads
52//! `<value>.as_object_mut_or("<slot>")?` and the guard-shape lives at
53//! ONE substrate owner here. The composed `anyhow::Error`'s `Display`
54//! is byte-identical to the pre-lift chain (`"<slot> is not an
55//! object"`), so operator-facing log output and any error-chain greps
56//! still match bytewise. A regression that drifts the message (a
57//! `"<slot> is not a JSON object"` synonym, a swapped `<slot>` slot,
58//! a promotion to a chain-form `source` that only surfaces via the
59//! alternate `{e:#}` formatter) surfaces at the tests below rather
60//! than as silent operator-facing drift across the three pre-lift
61//! consumers.
62//!
63//! ### Naming — `as_object_mut_or`, not `as_object_mut`
64//!
65//! Same discipline as the three sibling traits above — the trait
66//! method deliberately does NOT share a name with the inherent
67//! `serde_json::Value::as_object_mut` method (which returns
68//! `Option<&mut Map>`), because a name collision would let a caller
69//! who has `ValueObjectExt` in scope resolve to the inherent method
70//! by accident (inherent methods win over trait methods in method
71//! resolution) and silently drop the type-guard wrap altogether. The
72//! `_or` suffix names the intent: guard the `Option → Result` step
73//! at the same call, matching the pre-lift `.as_object_mut().
74//! ok_or_else(...)` chain.
75//!
76//! ### `#[must_use]`
77//!
78//! Every consumer threads the `?` short-circuit onto its handler's
79//! `Result<_, anyhow::Error>` return — dropping the guard swallows
80//! the underlying type-mismatch entirely, which is never the intended
81//! semantic at any of the three pre-lift consumers (each downstream
82//! `md.entry(...).or_insert_with(...)` / `annot.insert(...)` mutation
83//! depends on the returned `&mut Map` reference).
84//!
85//! Theory anchor: THEORY.md §VI.1 (generation over composition — the
86//! `.as_object_mut().ok_or_else(|| anyhow!("<slot> is not an
87//! object"))` guard-shape recurred at three hand-authored sites past
88//! the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
89//! ONE substrate owner here). THEORY.md §II.1 invariant 5 (composition
90//! preserves proofs — a regression that drifts the guard message
91//! wording at ONE site surfaces here at the substrate pin rather than
92//! as silent operator-facing skew across every SSA-time
93//! `metadata_object_mut` + `inject_annotations` mutation).
94
95use serde_json::{Map, Value};
96
97/// Substrate extension trait over `serde_json::Value` — the ONE
98/// substrate owner of the `.as_object_mut().ok_or_else(|| anyhow!(
99/// "<slot> is not an object"))` guard-shape. See the module docs for
100/// the full callsite audit + the naming rationale (why
101/// `as_object_mut_or` and not `as_object_mut`).
102pub trait ValueObjectExt {
103 /// Borrow the [`Value`] as a mutable JSON object [`Map`], or fail
104 /// loud with an [`anyhow::Error`] whose `Display` reads exactly
105 /// `"<slot> is not an object"` — the pre-lift wire format every
106 /// consumer's `tracing::error!(error = %e, ...)` log line already
107 /// encoded.
108 #[must_use = "an object-guard that isn't threaded via `?` swallows the underlying type mismatch"]
109 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>>;
110}
111
112impl ValueObjectExt for Value {
113 #[inline]
114 fn as_object_mut_or(&mut self, slot: &'static str) -> anyhow::Result<&mut Map<String, Value>> {
115 self.as_object_mut()
116 .ok_or_else(|| anyhow::anyhow!("{slot} is not an object"))
117 }
118}
119
120/// Substrate extension trait over `serde_json::Map<String, Value>` —
121/// the ONE substrate owner of the `map.insert(<key>.into(),
122/// Value::String(<val>.into()))` string-slot insertion shape every
123/// JSON-mutating helper in the workspace hand-authored at each callsite.
124///
125/// Peer of [`ValueObjectExt`] above on the JSON-mutation axis, split
126/// by SHAPE: [`ValueObjectExt::as_object_mut_or`] owns the "walk into
127/// this `Value`'s object-shape interior or fail loud" guard;
128/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
129/// a string-typed key" write shape that every consumer downstream of
130/// the guard uses to populate the returned `&mut Map`.
131///
132/// Pre-lift the shape was hand-authored at THIRTEEN production emit
133/// sites across `tatara-reconciler` past the ★★ PRIME-DIRECTIVE ≥ 2
134/// duplication threshold:
135///
136/// * `ssapply::inject_annotations` × 4 — the SSA-time observed-*
137/// annotation stamp family (`PID`, `CONTENT_HASH`, `GENERATION`,
138/// `ATTESTATION_ROOT`) each restated the 2-line `annot.insert(
139/// <annotation-const>.to_string(), Value::String(<val>.<coerce>))`
140/// shape verbatim.
141/// * `render::render_flux` × 3 — the Flux `Kustomization.spec` seeds
142/// (`interval`, `path`, `targetNamespace`), each restating the same
143/// `spec.insert("<key>".into(), Value::String(<val>))` shape.
144/// * `render::render_aplicacao` × 3 — the Flux `HelmRelease.spec`
145/// seeds (`releaseName`, `targetNamespace`) plus the values-overlay
146/// `profile` slot, each restating the same insert shape.
147/// * `render::render_export_job` × 2 — the export-Job outer label map
148/// (`ROLE`, `EXPORT_INDEX`) each restating the same insert shape.
149/// * `edges::IngressEdge::render` × 1 — the cert-manager
150/// `cluster-issuer` annotation, restating the same insert shape.
151///
152/// All THIRTEEN pre-lift sites restated the SAME 2-line shape verbatim,
153/// differing only in the `&'static str` / `String` key + the `&str` /
154/// `String` value at each callsite. A copy-paste that dropped the
155/// `Value::String(...)` wrap (a caller who reached for
156/// `.insert(k, v)` after refactoring from a `Value` slot to a plain
157/// `String` value slot) would type-check silently at every callsite —
158/// `Map<String, Value>::insert` expects a `Value`, and `String:
159/// Into<Value>` is provided by `serde_json` via the `Value::String`
160/// arm's `From` impl, so the naive `.insert(k, v.to_string())` compiles
161/// AND writes the byte-identical JSON. Post-lift each callsite reads
162/// `<map>.insert_str(<key>, <val>)` and the string-slot write shape
163/// lives at ONE substrate owner here.
164///
165/// ### Composability
166///
167/// * Key slot accepts any `impl Into<String>`: `&str` (via
168/// `String::from`), `String` (identity), `Cow<'_, str>`, so a
169/// callsite with a static `annotations::PID` (`&'static str`) reads
170/// `insert_str(annotations::PID, …)` with no `.to_string()` per site.
171/// * Value slot accepts any `impl Into<String>`: `&str`, `String`,
172/// `Cow<'_, str>`. Numeric or non-string values still need an
173/// explicit `.to_string()` at the callsite — same as pre-lift, so
174/// the wrapping shape stays visible in the caller's grep footprint.
175/// * Returns `Option<Value>` matching the inherent
176/// `Map<String, Value>::insert` return semantics: `None` on new-key,
177/// `Some(prev)` on overwrite of an existing slot.
178///
179/// ### Naming — `insert_str`, not `insert`
180///
181/// Same discipline as [`ValueObjectExt::as_object_mut_or`] above — the
182/// trait method deliberately does NOT collide with the inherent
183/// `Map::insert` (which takes `(String, Value)` positionally). A name
184/// collision would let a caller who has `JsonMapStrExt` in scope
185/// resolve to the inherent method by accident (inherent methods win
186/// over trait methods in method resolution) and silently drop the
187/// `Value::String` wrap, stamping the value bytes straight into the
188/// map under a different `Value` variant. The `_str` suffix names the
189/// intent: the value slot IS the `Value::String` arm at this write.
190///
191/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
192/// `.insert(<k>.into(), Value::String(<v>.into()))` shape recurred at
193/// THIRTEEN hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
194/// duplication trigger, and is lifted to ONE substrate owner here).
195/// THEORY.md §II.1 invariant 5 (composition preserves proofs — a
196/// regression that drifts the string-slot write shape at ONE consumer
197/// surfaces at the substrate pin rather than as silent per-emit skew
198/// across every ssapply / render / edges JSON emit site).
199pub trait JsonMapStrExt {
200 /// Insert a `Value::String(<val>.into())` at `<key>.into()` into
201 /// this JSON object map. Returns `Option<Value>` matching the
202 /// underlying `Map::insert` semantics — `None` for a new key,
203 /// `Some(prev)` for an overwrite.
204 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value>;
205}
206
207impl JsonMapStrExt for Map<String, Value> {
208 #[inline]
209 fn insert_str(&mut self, key: impl Into<String>, value: impl Into<String>) -> Option<Value> {
210 self.insert(key.into(), Value::String(value.into()))
211 }
212}
213
214/// Substrate extension trait over `serde_json::Map<String, Value>` —
215/// the ONE substrate owner of the `.entry(<key>).or_insert_with(||
216/// Value::Object(<empty>))` seed-then-guard shape every JSON-mutating
217/// helper hand-authored at the "walk into this object slot on the
218/// parent map, seeding an empty object if the slot is absent, or fail
219/// loud if the slot exists but is a non-object" boundary.
220///
221/// Peer of [`ValueObjectExt::as_object_mut_or`] and
222/// [`JsonMapStrExt::insert_str`] on the JSON-mutation axis; split by
223/// SHAPE + SITE. [`ValueObjectExt::as_object_mut_or`] owns the "guard
224/// a `Value` handle into its object interior" step at ONE level;
225/// [`JsonMapStrExt::insert_str`] owns the "stamp a `Value::String` at
226/// a string-typed key" write shape; this trait owns the compound
227/// "get-or-seed the object at a slot, then guard" step every SSA-time
228/// re-injection walks when the caller intends to reach a nested
229/// object slot without asserting whether the parent has already
230/// populated it (a caller composing a fresh resource-body carries
231/// no `metadata` / `metadata.annotations` slot pre-seed; a caller
232/// composing atop a pre-populated resource does — both paths reach
233/// the same primitive).
234///
235/// Pre-lift the compound shape was hand-authored at TWO adjacent
236/// private helpers in `tatara-reconciler::ssapply` past the ★★
237/// PRIME-DIRECTIVE ≥ 2 duplication threshold, both walking the SAME
238/// 3-step `let X = <map>.entry(<slot>).or_insert_with(|| Value::Object
239/// (<empty>)); X.as_object_mut_or(<slot>)?` incantation:
240///
241/// * `metadata_object_mut(resource)` — the `metadata` slot seed-then-
242/// guard step at the root of every SSA-time re-injection walk
243/// (`inject_owner_reference` + `inject_annotations` reach it).
244/// * `inject_annotations(resource, process)` — the `annotations`
245/// slot seed-then-guard step nested one level deeper under the
246/// `metadata` object the primitive above returned.
247///
248/// Both restated the SAME 3-line shape verbatim: `.entry(<slot>)` on
249/// a `Map<String, Value>` handle known to be an object, then
250/// `.or_insert_with(|| Value::Object(<empty>))` to synthesize an
251/// empty object at the slot when absent, then a `.as_object_mut_or
252/// (<slot>)?` guard on the returned `&mut Value` to fail loud when
253/// the existing slot is a non-object. TWO byte-for-byte identical
254/// blocks past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold,
255/// differing only in the `&'static str` slot name each callsite
256/// stamped (`"metadata"` / `"annotations"`) — and the slot name is
257/// used at BOTH the entry key AND the guard error message so a
258/// regression that drifted the two apart at one callsite (a typo
259/// stamping `"metadata"` into the entry key + `"metadatas"` into
260/// the error message) would silently pass one pin and fail the
261/// other. Post-lift each callsite reads `<map>.object_slot_mut_or
262/// (<slot>)?` and the compound shape lives at ONE substrate owner
263/// here — the slot name is stamped ONCE per call and reaches both
264/// the entry key and the guard error slot mechanically.
265///
266/// ### Composability
267///
268/// * Slot name is `&'static str` — pre-lift both callsites stamped
269/// `&'static str` literals (`"metadata"` / `"annotations"`); a
270/// dynamic-slot caller (a callsite that reached this primitive
271/// with a `String` key computed at runtime) has no pre-lift
272/// precedent in the ssapply/render axis, so the `&'static str`
273/// bound stays honest to the pre-lift shape. A future caller
274/// needing a runtime slot name can widen this to
275/// `impl Into<String>` at the substrate; the pre-lift consumers
276/// inherit it mechanically.
277/// * Returns `anyhow::Result<&mut Map<String, Value>>` — matches the
278/// sibling [`ValueObjectExt::as_object_mut_or`] shape so the
279/// downstream `.entry(...).or_insert_with(...)` / `.insert(...)`
280/// mutation threads through `?` onto the caller's
281/// `Result<_, anyhow::Error>` return exactly as pre-lift.
282/// * Ok-arm returns the SAME `&mut Map<String, Value>` the pre-lift
283/// `.as_object_mut_or(<slot>)` step returned — no clone, no key-
284/// order reshape, no synthesis.
285///
286/// ### Naming — `object_slot_mut_or`, not `entry_object` or
287/// `get_or_insert_object_mut`
288///
289/// Same discipline as the two sibling traits above — the trait method
290/// deliberately does NOT collide with the inherent `Map::entry` /
291/// `Map::get_mut` / `Map::insert` methods (any of which a caller who
292/// has this trait in scope could resolve to by accident, silently
293/// dropping the type-guard step). The `_or` suffix names the intent
294/// (guard the `Option → Result` step at the same call, matching the
295/// pre-lift `.as_object_mut_or(<slot>)?` guard); `object_slot_mut`
296/// names the target shape (return an `&mut` object-typed `Map` at
297/// the slot). Together they read as "guard the slot into a mutable
298/// object interior or fail loud", matching the pre-lift semantics
299/// exactly.
300///
301/// ### `#[must_use]`
302///
303/// Every consumer threads the `?` short-circuit onto its handler's
304/// `Result<_, anyhow::Error>` return — dropping the guard swallows
305/// the underlying type-mismatch entirely, which is never the intended
306/// semantic at either pre-lift consumer (each downstream
307/// `.entry(...).or_insert_with(...)` / `.insert(...)` mutation
308/// depends on the returned `&mut Map` reference).
309///
310/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
311/// 3-line `.entry(<slot>).or_insert_with(|| Value::Object(<empty>))
312/// .as_object_mut_or(<slot>)?` compound shape recurred at two
313/// hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
314/// trigger, and is lifted to ONE substrate owner here). THEORY.md
315/// §II.1 invariant 5 (composition preserves proofs — a regression
316/// that drifted the entry-key slot vs. the guard-error slot at ONE
317/// site would silently pass one downstream pin and fail the other;
318/// post-lift the primitive stamps the slot ONCE per call so the
319/// substrate itself owns the entry-key ↔ guard-error name coherence).
320pub trait JsonMapObjectEntryExt {
321 /// Get-or-seed the object at `slot` in this JSON map, then guard
322 /// that the resulting handle is an object; returns
323 /// `&mut Map<String, Value>` on the object arm, and an
324 /// [`anyhow::Error`] whose `Display` reads
325 /// `"<slot> is not an object"` on the non-object arm (byte-
326 /// identical to the pre-lift `.as_object_mut_or(<slot>)?` guard,
327 /// sourced from the sibling [`ValueObjectExt::as_object_mut_or`]).
328 #[must_use = "an object-slot guard that isn't threaded via `?` swallows the underlying type mismatch"]
329 fn object_slot_mut_or(&mut self, slot: &'static str)
330 -> anyhow::Result<&mut Map<String, Value>>;
331}
332
333impl JsonMapObjectEntryExt for Map<String, Value> {
334 #[inline]
335 fn object_slot_mut_or(
336 &mut self,
337 slot: &'static str,
338 ) -> anyhow::Result<&mut Map<String, Value>> {
339 self.entry(slot)
340 .or_insert_with(|| Value::Object(Map::new()))
341 .as_object_mut_or(slot)
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348 use serde_json::json;
349
350 // ─── ValueObjectExt::as_object_mut_or substrate pins ─────────────
351 //
352 // Fail-before-pass-after granularity: the `ValueObjectExt::
353 // as_object_mut_or` trait method did not exist before this commit,
354 // so each test below fails to compile pre-lift. Post-lift they
355 // collectively pin the object-guard shape at ONE substrate owner —
356 // a regression that drifts the error message wording, swaps the
357 // `<slot>` slot, wraps the source in a chain-form `source` (which
358 // would change `Display` output when downstream tracing formatters
359 // interpolate `{e}` rather than the chain-walking `{e:#}`), or
360 // promotes the pass-through arm to synthesis (a `None → Ok(&mut
361 // Map::default())` fallthrough that silently swallows a mistyped
362 // slot) surfaces HERE rather than as silent operator-facing skew
363 // across the three `ssapply.rs` pre-lift consumers whose log
364 // output already encoded the flat `"<slot> is not an object"`
365 // shape.
366
367 #[test]
368 fn as_object_mut_or_object_arm_returns_the_inner_map_mutably() {
369 // Ok-arm invariant: a `Value::Object` handle threaded through
370 // `as_object_mut_or("<slot>")` MUST return `Ok(&mut Map)`
371 // whose interior is the SAME `serde_json::Map` the underlying
372 // `serde_json::Value::as_object_mut` would return — no clone,
373 // no reshape, no synthesis. The `&mut` return is load-bearing
374 // at every consumer (each threads a downstream `.entry(...).
375 // or_insert_with(...)` / `.insert(...)` mutation onto the
376 // returned reference), so a regression that returned a fresh
377 // owned `Map` here would silently drop every downstream write.
378 let mut v = json!({ "existing_key": "existing_value" });
379 let map = v.as_object_mut_or("resource").expect("Value::Object");
380 map.insert("new_key".to_string(), json!("new_value"));
381 assert_eq!(v["existing_key"], "existing_value");
382 assert_eq!(v["new_key"], "new_value");
383 }
384
385 #[test]
386 fn as_object_mut_or_null_arm_errors_with_pre_lift_display_bytewise() {
387 // Byte-shape parity pin: the wrap output of `as_object_mut_or
388 // ("<slot>")` on a `Value::Null` handle MUST be `Display`-
389 // identical to the pre-lift hand-authored `.as_object_mut().
390 // ok_or_else(|| anyhow!("<slot> is not an object"))?` chain.
391 // A regression that inserted a synonym (`"<slot> is not a
392 // JSON object"`), reshaped the slot position (`"not an
393 // object: <slot>"`), or dropped the leading `<slot>` slot
394 // surfaces HERE rather than as silent drift at every
395 // downstream log-output consumer.
396 let mut v = Value::Null;
397 let err = v.as_object_mut_or("resource").unwrap_err();
398 assert_eq!(format!("{err}"), "resource is not an object");
399 }
400
401 #[test]
402 fn as_object_mut_or_array_arm_errors_with_pre_lift_display_bytewise() {
403 // Sibling to the null-arm byte-shape pin — a mistyped
404 // `metadata` slot authored as a JSON array (kubectl accepts
405 // `metadata: []` in a YAML manifest with no schema, though the
406 // apiserver later rejects it) surfaces the same guard error.
407 // Pins the "non-object variants ALL error via the same wire
408 // format" invariant — a regression that special-cased the
409 // array variant (returning a fresh empty map, silently
410 // coercing) surfaces HERE.
411 let mut v = json!(["not", "an", "object"]);
412 let err = v.as_object_mut_or("metadata").unwrap_err();
413 assert_eq!(format!("{err}"), "metadata is not an object");
414 }
415
416 #[test]
417 fn as_object_mut_or_string_arm_errors_with_pre_lift_display_bytewise() {
418 // Sibling to the null / array pins — a mistyped `annotations`
419 // slot authored as a JSON string (a common apiserver-layer
420 // authoring bug in kubectl-generated manifests where a
421 // stringified JSON object leaks through) surfaces the same
422 // guard error. Pins the "every non-object variant errors via
423 // the same wire format" invariant across the full
424 // `serde_json::Value` sum.
425 let mut v = json!("stringified");
426 let err = v.as_object_mut_or("annotations").unwrap_err();
427 assert_eq!(format!("{err}"), "annotations is not an object");
428 }
429
430 #[test]
431 fn as_object_mut_or_threads_the_slot_slug_verbatim_across_all_three_pre_lift_labels() {
432 // Cross-slot coherence pin: the three pre-lift consumers in
433 // `tatara-reconciler::ssapply` stamped THREE distinct slot
434 // slugs (`"resource"` / `"metadata"` / `"annotations"`), and
435 // the wrap-shape MUST honor each one verbatim as the leading
436 // slot in the `Display` output. A regression that hard-coded
437 // one slug (say `"resource"`) across every callsite would
438 // pass the first pin above and fail HERE — the three
439 // downstream error-stream greps operators run to bisect a
440 // "which SSA-time mutation faulted" alert would ALL collapse
441 // to the same slug.
442 for slot in ["resource", "metadata", "annotations"] {
443 let mut v = Value::Null;
444 let err = v.as_object_mut_or(slot).unwrap_err();
445 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
446 }
447 }
448
449 #[test]
450 fn as_object_mut_or_object_arm_matches_inherent_as_object_mut_bytewise() {
451 // Cross-substrate coherence pin: on the Ok arm the trait
452 // method MUST return the SAME `&mut Map` the inherent
453 // `serde_json::Value::as_object_mut` would — no diverging
454 // view, no clone, no key-order reshape. A regression that
455 // introduced a normalization pass here (sorting keys,
456 // stripping a null-valued entry, coercing a nested string
457 // to a JSON scalar) would surface as silent per-consumer
458 // schema drift at the SSA-time mutation — an ownerReferences
459 // append that no longer landed in the same slot the apiserver
460 // reads, an annotations insert whose key ordering diverged
461 // from kubectl's canonical form.
462 let mut via_trait = json!({ "key": "value", "nested": { "inner": 1 } });
463 let mut via_inherent = via_trait.clone();
464 assert_eq!(
465 via_trait
466 .as_object_mut_or("resource")
467 .expect("Value::Object")
468 .clone(),
469 via_inherent.as_object_mut().expect("Value::Object").clone(),
470 );
471 }
472
473 // ─── JsonMapStrExt::insert_str substrate pins ─────────────────
474 //
475 // Fail-before-pass-after granularity: the `JsonMapStrExt::insert_str`
476 // trait method did not exist before this commit, so each test below
477 // fails to compile pre-lift. Post-lift they collectively pin the
478 // string-slot write shape at ONE substrate owner — a regression that
479 // dropped the `Value::String` wrap (silently coercing to a bare
480 // `Value::from(&str)` — byte-identical in the `Object` arm today but
481 // divergent for any future non-`&str` numeric caller who reached for
482 // `insert_str(k, n.to_string())`), swapped the key + value slot
483 // orientation, or drifted the return semantics from the inherent
484 // `Map::insert` (which returns the previous value on overwrite —
485 // load-bearing at any future caller that inspects the return) would
486 // surface HERE rather than as silent per-emit skew across the
487 // thirteen pre-lift `ssapply` + `render` + `edges` consumers.
488
489 #[test]
490 fn insert_str_new_key_returns_none_and_stamps_value_string() {
491 // New-key arm: matches inherent `Map::insert` return
492 // semantics — `None` for a fresh key — and stamps a
493 // `Value::String` (NOT `Value::from(&str)`, though they're
494 // byte-identical today) at the slot.
495 let mut m = Map::new();
496 let prev = m.insert_str("key", "value");
497 assert!(prev.is_none(), "new key returns None");
498 assert_eq!(m.get("key"), Some(&Value::String("value".to_string())));
499 assert!(matches!(m.get("key"), Some(Value::String(_))));
500 }
501
502 #[test]
503 fn insert_str_overwrite_returns_prior_value_and_stamps_new() {
504 // Overwrite arm: matches inherent `Map::insert` return
505 // semantics — `Some(prev)` on overwrite. Load-bearing for
506 // any future consumer that inspects the return to detect a
507 // slot collision (a fleet-wide sweep that flagged a
508 // duplicate SSA-time annotation stamp, for example).
509 let mut m = Map::new();
510 m.insert_str("key", "old");
511 let prev = m.insert_str("key", "new");
512 assert_eq!(prev, Some(Value::String("old".to_string())));
513 assert_eq!(m.get("key"), Some(&Value::String("new".to_string())));
514 }
515
516 #[test]
517 fn insert_str_accepts_str_and_owned_string_at_both_slots() {
518 // Composability pin: both slots MUST accept `&str` and
519 // `String` interchangeably — the pre-lift callsite inventory
520 // mixes both (SSA-time `annotations::PID` static + a
521 // `pid.to_string()` runtime String at the value slot;
522 // `spec.insert("interval".into(), Value::String("1m".into()))`
523 // with two `&str` slots). A regression that constrained
524 // either slot to one shape would break the callsite parity
525 // that motivated this substrate primitive.
526 let mut m1 = Map::new();
527 m1.insert_str("a", "b");
528 let mut m2 = Map::new();
529 m2.insert_str(String::from("a"), String::from("b"));
530 let mut m3 = Map::new();
531 m3.insert_str("a", String::from("b"));
532 let mut m4 = Map::new();
533 m4.insert_str(String::from("a"), "b");
534 assert_eq!(m1, m2);
535 assert_eq!(m2, m3);
536 assert_eq!(m3, m4);
537 }
538
539 #[test]
540 fn insert_str_matches_pre_lift_hand_authored_shape_bytewise() {
541 // Byte-shape parity pin: `insert_str(k, v)` MUST emit the
542 // SAME `Map` entry the pre-lift hand-authored `.insert(
543 // <k>.into(), Value::String(<v>.into()))` chain produced.
544 // Sweeps the four (str × String) × (str × String) key/value
545 // shape quadrants so a regression at the primitive that
546 // broke the byte identity with the pre-lift shape at ONE
547 // quadrant surfaces here rather than as a subtle per-emit
548 // divergence at that quadrant.
549 for (k_str, v_str) in [("a", "b"), ("x", ""), ("", "y"), ("", "")] {
550 // (str, str) quadrant
551 let mut via_primitive = Map::new();
552 via_primitive.insert_str(k_str, v_str);
553 let mut via_pre_lift = Map::new();
554 via_pre_lift.insert(k_str.into(), Value::String(v_str.into()));
555 assert_eq!(via_primitive, via_pre_lift);
556
557 // (String, String) quadrant
558 let mut via_primitive = Map::new();
559 via_primitive.insert_str(String::from(k_str), String::from(v_str));
560 let mut via_pre_lift = Map::new();
561 via_pre_lift.insert(String::from(k_str), Value::String(String::from(v_str)));
562 assert_eq!(via_primitive, via_pre_lift);
563 }
564 }
565
566 #[test]
567 fn insert_str_empty_value_stamps_empty_string_not_null() {
568 // Semantic pin: an empty value slot MUST stamp
569 // `Value::String("")`, NEVER `Value::Null`. Load-bearing at
570 // any callsite that stamps a placeholder empty-string
571 // annotation (say a `content_hash` slot pre-derive) where a
572 // `Null` slot would fail-loud at the K8s apiserver's
573 // annotation-value type check.
574 let mut m = Map::new();
575 m.insert_str("empty", "");
576 assert_eq!(m.get("empty"), Some(&Value::String(String::new())));
577 assert!(!matches!(m.get("empty"), Some(Value::Null)));
578 }
579
580 // ─── JsonMapObjectEntryExt::object_slot_mut_or substrate pins ─────
581 //
582 // Fail-before-pass-after granularity: the
583 // `JsonMapObjectEntryExt::object_slot_mut_or` trait method did not
584 // exist before this commit, so each test below fails to compile
585 // pre-lift. Post-lift they collectively pin the compound
586 // seed-then-guard shape at ONE substrate owner — a regression that
587 // dropped the seed step (leaving an absent slot to fall through the
588 // guard as `None → Err`), skipped the guard step (silently returning
589 // an `&mut Value` when the existing slot is a non-object variant),
590 // drifted the entry-key slot vs. the guard-error slot (a copy-paste
591 // typo that stamped `"metadata"` into the entry and `"metadatas"`
592 // into the guard error message), or drifted the empty-seed shape
593 // (a `Value::Null` fallback where `Value::Object(Map::new())` is
594 // load-bearing at the downstream `.entry(...).or_insert_with(...)`
595 // / `.insert(...)` mutation) would surface HERE rather than as
596 // silent per-emit skew across the two pre-lift `ssapply.rs`
597 // consumers.
598
599 #[test]
600 fn object_slot_mut_or_absent_slot_seeds_empty_object_and_returns_it() {
601 // Absent-slot arm: the pre-lift `.entry(<slot>).or_insert_with
602 // (|| Value::Object(Default::default()))` step MUST seed the
603 // slot with an EMPTY `Value::Object` when the slot is not
604 // present in the parent map. The returned handle is the fresh
605 // empty map, MUTABLY, so a downstream `.insert(...)` writes
606 // land in the parent map's `<slot>` object post-return.
607 let mut parent = Map::new();
608 {
609 let child = parent
610 .object_slot_mut_or("metadata")
611 .expect("absent slot seeds an object");
612 assert!(child.is_empty(), "fresh-seeded slot is an empty object");
613 child.insert("name".into(), Value::String("demo".into()));
614 }
615 // The write landed in the parent map's metadata slot.
616 assert_eq!(parent["metadata"]["name"], "demo");
617 assert!(matches!(parent.get("metadata"), Some(Value::Object(_))));
618 }
619
620 #[test]
621 fn object_slot_mut_or_present_object_slot_returns_existing_interior_mutably() {
622 // Present-object-slot arm: when the slot is already populated
623 // with a `Value::Object`, the primitive MUST return the
624 // EXISTING map interior mutably — no synthesis, no reshape, no
625 // key-order rewrite. The downstream `.insert(...)` writes MUST
626 // merge into the pre-existing keys rather than replace them.
627 let mut parent = Map::new();
628 parent.insert(
629 "metadata".into(),
630 serde_json::json!({ "existing_key": "existing_value" }),
631 );
632 {
633 let child = parent
634 .object_slot_mut_or("metadata")
635 .expect("present-object slot returns Ok");
636 assert_eq!(
637 child.get("existing_key"),
638 Some(&Value::String("existing_value".into()))
639 );
640 child.insert("new_key".into(), Value::String("new_value".into()));
641 }
642 assert_eq!(parent["metadata"]["existing_key"], "existing_value");
643 assert_eq!(parent["metadata"]["new_key"], "new_value");
644 }
645
646 #[test]
647 fn object_slot_mut_or_present_non_object_slot_errors_with_pre_lift_display() {
648 // Fail-loud arm: when the slot is present but holds a non-
649 // object variant (a `Value::String` from a hand-authored
650 // YAML manifest where `metadata: "malformed"` slipped past
651 // kubectl's schema check), the primitive MUST fail with a
652 // `Display` byte-identical to the pre-lift
653 // `.as_object_mut_or(<slot>)?` guard — the sibling
654 // [`ValueObjectExt::as_object_mut_or`] guard's wire format.
655 // A regression that special-cased this arm (overwriting the
656 // slot with a fresh empty object, silently coercing) would
657 // silently swallow the operator's authoring error at the
658 // SSA-time re-injection step.
659 let mut parent = Map::new();
660 parent.insert("metadata".into(), Value::String("malformed".into()));
661 let err = parent.object_slot_mut_or("metadata").unwrap_err();
662 assert_eq!(format!("{err}"), "metadata is not an object");
663 }
664
665 #[test]
666 fn object_slot_mut_or_threads_the_slot_slug_verbatim_across_both_pre_lift_labels() {
667 // Cross-slot coherence pin: the TWO pre-lift consumers in
668 // `tatara-reconciler::ssapply` stamped TWO distinct slot slugs
669 // (`"metadata"` at the resource root, `"annotations"` at the
670 // metadata child), and the wrap-shape MUST honor each one
671 // verbatim as the leading slot in the `Display` output. A
672 // regression that hard-coded one slug across every callsite
673 // would pass the fail-loud pin above (on the `"metadata"` slug)
674 // and fail HERE — the two downstream error-stream greps
675 // operators run to bisect a "which SSA-time slot mutation
676 // faulted" alert would ALL collapse to the same slug, hiding
677 // whether the fault was at the resource-root object walk or
678 // the metadata-child annotations walk.
679 for slot in ["metadata", "annotations"] {
680 let mut parent = Map::new();
681 parent.insert(slot.into(), Value::Null);
682 let err = parent.object_slot_mut_or(slot).unwrap_err();
683 assert_eq!(format!("{err}"), format!("{slot} is not an object"));
684 }
685 }
686
687 #[test]
688 fn object_slot_mut_or_present_empty_object_returns_existing_reference_not_synthesized() {
689 // Precedence pin: a present slot holding an EMPTY
690 // `Value::Object` MUST return the pre-existing empty map
691 // interior — not a freshly-synthesized replacement. The
692 // pre-lift `.entry(<slot>).or_insert_with(||...)` step's
693 // short-circuit on the present-slot arm skips the closure
694 // entirely; a regression that always evaluated the closure
695 // (unconditionally overwriting an existing empty-object slot
696 // with a fresh empty object) would type-check silently at
697 // every callsite AND write byte-identical JSON at the empty-
698 // slot corner, but it would break a hypothetical future
699 // consumer that reached the primitive on a map whose slot
700 // was seeded upstream with metadata (a caller intending to
701 // preserve any keys the parent-composer already dropped in).
702 let mut parent = Map::new();
703 parent.insert("metadata".into(), Value::Object(Map::new()));
704 let addr_before = parent.get("metadata").unwrap() as *const Value;
705 {
706 let _child = parent.object_slot_mut_or("metadata").unwrap();
707 }
708 let addr_after = parent.get("metadata").unwrap() as *const Value;
709 assert_eq!(
710 addr_before, addr_after,
711 "present empty-object slot must return the pre-existing reference, not a fresh synthesis",
712 );
713 }
714
715 #[test]
716 fn object_slot_mut_or_matches_pre_lift_hand_authored_compound_shape_bytewise() {
717 // Byte-shape parity pin: `object_slot_mut_or(<slot>)?` MUST
718 // produce the SAME `&mut Map` (and, on the non-object arm, the
719 // SAME `Display`-shaped error) the pre-lift 3-line `.entry
720 // (<slot>).or_insert_with(|| Value::Object(Default::default()))
721 // .as_object_mut_or(<slot>)?` chain produced. Sweeps the three
722 // pre-lift-reachable input corners (absent slot / present
723 // object / present non-object) so a regression at the primitive
724 // that broke byte identity with the pre-lift chain at ONE
725 // corner surfaces here rather than as a subtle per-emit
726 // divergence.
727 for slot in ["metadata", "annotations"] {
728 // (1) Absent-slot corner: both routes seed empty-object at
729 // the slot AND return the same empty map interior.
730 let mut via_primitive = Map::new();
731 let mut via_pre_lift = Map::new();
732 {
733 let _ = via_primitive.object_slot_mut_or(slot).unwrap();
734 let _ = via_pre_lift
735 .entry(slot.to_string())
736 .or_insert_with(|| Value::Object(Map::new()))
737 .as_object_mut_or(slot)
738 .unwrap();
739 }
740 assert_eq!(via_primitive, via_pre_lift);
741
742 // (2) Present-object corner: both routes read back the
743 // same pre-populated interior mutably.
744 let mut via_primitive = Map::new();
745 via_primitive.insert(slot.into(), serde_json::json!({ "k": "v" }));
746 let mut via_pre_lift = via_primitive.clone();
747 {
748 let a = via_primitive.object_slot_mut_or(slot).unwrap();
749 let b = via_pre_lift
750 .entry(slot.to_string())
751 .or_insert_with(|| Value::Object(Map::new()))
752 .as_object_mut_or(slot)
753 .unwrap();
754 assert_eq!(a, b);
755 }
756
757 // (3) Present-non-object corner: both routes fail loud
758 // with the same wire-format Display shape.
759 let mut via_primitive = Map::new();
760 via_primitive.insert(slot.into(), Value::Bool(true));
761 let mut via_pre_lift = via_primitive.clone();
762 let err_primitive = via_primitive.object_slot_mut_or(slot).unwrap_err();
763 let err_pre_lift = via_pre_lift
764 .entry(slot.to_string())
765 .or_insert_with(|| Value::Object(Map::new()))
766 .as_object_mut_or(slot)
767 .unwrap_err();
768 assert_eq!(format!("{err_primitive}"), format!("{err_pre_lift}"));
769 }
770 }
771}