pub trait ValueGetExt {
// Required methods
fn get_i64(&self, key: &str) -> Option<i64>;
fn get_str(&self, key: &str) -> Option<&str>;
fn get_array(&self, key: &str) -> Option<&Vec<Value>>;
fn get_bool(&self, key: &str) -> Option<bool>;
}Expand description
Substrate extension trait over serde_json::Value — the ONE
substrate owner of the paired .get(<key>).and_then(|v| v.as_<T>())
two-link READ chain every downstream projection walks to pull a
typed leaf off a Kubernetes-status blob (or an equivalent
rendered-resource JSON object) without asserting the slot is
present, without asserting its variant, and without asserting the
slot fits the target scalar type.
The trait carries ONE method per typed READ axis; the axis-family
is Self::get_i64 (integer counters) + Self::get_str
(string slots) + Self::get_array (JSON array slots) +
Self::get_bool (boolean flags). Adding a further axis
(a get_object for Value::Object, a get_f64 for
Value::Number truncated to f64) lands as ONE new method here
- ONE impl arm per receiver shape, inheriting the naming,
#[must_use], and inline discipline the existing axes pin. Never open a peer trait for a new axis — keep every READ projection on the ONE substrate owner so a caller who importsValueGetExtreaches every axis through the same trait handle.
READ-side counterpart to the three MUTATION-side siblings already in
this module — ValueObjectExt::as_object_mut_or,
JsonMapStrExt::insert_str, JsonMapObjectEntryExt::object_slot_mut_or
— partitioning the substrate along the (read, mutate) axis on the
same serde_json::Value / serde_json::Map<String, Value> carrier
pair.
Pre-lift the two-link chain was hand-authored at THREE adjacent
slots inside tatara-reconciler::boundary::fetch_job_status, each
projecting one batch/v1::Job status.<counter> field out of the
fetched serde_json::Value object into a private JobStatusView
row past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
status.get("succeeded").and_then(|v| v.as_i64())— the Job-completion counter everyJobAttested+ClosedLoopAuthpostcondition evaluator gates on (succeeded < 1short-circuits toSatisfaction::Unsatisfied("… still running (…)")).status.get("failed").and_then(|v| v.as_i64())— the Job-failure counter the same evaluators gate on (failed > 0short-circuits toSatisfaction::Unsatisfied("… failed (status.failed={n})")).status.get("active").and_then(|v| v.as_i64())— the Job-in-flight counter the “still running” diagnostic tail reports as(succeeded={s}, active={a}).
All THREE sites walked the SAME two-link chain — .get(<key>) on a
serde_json::Value already known to be the status object, then
.and_then(|v| v.as_i64()) on the returned Option<&Value> — and
each was followed by an if let Some(...) write into the
[JobStatusView] row initialised from Default::default(). Post-
lift each callsite reads status.get_i64(<key>) and the two-link
READ chain lives at ONE substrate owner here.
§Naming — get_i64, not as_i64 or i64_at
Same discipline as the three sibling traits above — the trait method
deliberately does NOT collide with serde_json::Value::as_i64 (the
inherent projection on a single Value handle) nor with
serde_json::Value::get (the inherent slot-lookup returning
Option<&Value>). A name collision would let a caller who has
ValueGetExt in scope resolve to one of the inherent methods by
accident (inherent methods win over trait methods in method
resolution) and silently drop half of the paired chain. The
get_i64(<key>) shape names the intent: look up the slot at
<key>, project the returned handle to i64, in ONE call.
§#[must_use]
Every consumer either binds the returned Option<i64> into a
downstream if let Some(n) = ... / .unwrap_or_default() / struct-
field construction. Dropping the return silently discards the
projection entirely, which is never the intended semantic at the
three pre-lift consumers (each downstream write depends on the
returned counter).
§Composability
- Key slot is
&str— matches every pre-lift.get("<literal>")callsite and the inherentserde_json::Value::get’s primarystr-index arm. A caller with a runtime-computed key (aStringproduced by a template composer) reaches through.get_i64(&s)mechanically viaDeref<Target = str>. - Returns
Option<i64>matching the composed inherent chain’s own return; a consumer wanting the “absent or non-integer → 0” fallback composes.unwrap_or_default()(or.unwrap_or(0)) at the callsite, keeping the “should this counter default to 0 or fail loud” decision at the caller rather than baking it into the primitive. - Non-object receivers (a
Value::String, aValue::Null) returnNoneverbatim via the inherentValue::get’s own non-object- arm behaviour, matching the pre-lift chain’s semantics on the corner where the caller’s status blob is malformed.
A future normalization — a per-fleet clamp that rejects negative
counters (the K8s API server never emits them, but a fixture
authoring bug could), a Value::Number fallback that accepts
f64 counters truncated to i64, a checked overflow arm that
promotes an out-of-range integer to a diagnostic rather than a
silent None — lands at THIS ONE substrate primitive and every
downstream Job-status / Deployment-replica / HPA-desired-count
counter reader inherits the upgrade mechanically. No per-site edit
at any of the 3 listed callers or at future consumers (a
Deployment readyReplicas projection, an HPA currentReplicas
gate, a StatefulSet updatedReplicas freshness check).
Theory anchor: THEORY.md §VI.1 (generation over composition — the
two-link .get(<key>).and_then(|v| v.as_i64()) chain recurred at
three hand-authored sites past the ★★ PRIME-DIRECTIVE ≥ 2
duplication trigger, and is lifted to ONE substrate owner here).
THEORY.md §II.1 invariant 5 (composition preserves proofs — a
regression that drifted the projection axis at ONE site — a swap
of as_i64 for as_u64 narrowing the accepted range, a swap of
.get(<key>) for .pointer("<key>") losing the direct-child
semantics — would silently pass every downstream JobStatusView
composition and surface as a wrong counter at operator-facing
diagnostic wording; post-lift the projection lives at ONE typed
owner so a regression surfaces at [tests::get_i64_null_arm_returns_none]
/ peers rather than as silent operator-facing drift).
Required Methods§
Sourcefn get_i64(&self, key: &str) -> Option<i64>
fn get_i64(&self, key: &str) -> Option<i64>
Look up key on this JSON object and project the returned
handle to i64; returns None when the slot is absent, when
the receiver is not a JSON object, or when the slot’s variant
is not integer-shaped.
Sourcefn get_str(&self, key: &str) -> Option<&str>
fn get_str(&self, key: &str) -> Option<&str>
Look up key on this JSON object and project the returned
handle to &str; returns None when the slot is absent, when
the receiver is not a JSON object, or when the slot’s variant
is not Value::String.
String-axis sibling of Self::get_i64 on the same
.get(<key>).and_then(|v| v.as_<T>()) READ-chain lift. Pre-lift
the two-link chain was hand-authored at SEVEN production sites
across two crates past the ★★ PRIME-DIRECTIVE ≥ 2 duplication
threshold:
tatara-process::status::RenderedResourceCoords::from_json— FOUR paired reads (apiVersion,kind,metadata.name,metadata.namespace) that project the four rendered-resource coordinate slots off aserde_json::Valuerendered manifest into the typedRenderedResourceCoordsrow; the required three (apiVersion/kind/metadata.name) compose with.ok_or_else(|| anyhow!("rendered resource missing X"))? .to_string(), and the optionalmetadata.namespacecomposes with.map(str::to_string).tatara-reconciler::ssapply::ready_condition_value— THREE paired reads (type,status,message) inside the condition-walker’s per-condition classifier, each pulling aValue::Stringslot off a K8s Condition object off thestatus.conditions[]array.
All seven sites walked the SAME two-link chain — .get(<key>)
on a serde_json::Value already known to be an object, then
.and_then(|v| v.as_str()) on the returned Option<&Value> —
and each composed different downstream tails (fallible
.ok_or_else(...)?.to_string(), optional .map(String::from),
pattern-match Some("True") / Some("False") / _). Post-lift
each callsite reads <value>.get_str(<key>) and the two-link
READ chain lives at ONE substrate owner here.
§Naming — get_str, not as_str or str_at
Same discipline as Self::get_i64 — the trait method
deliberately does NOT collide with serde_json::Value::as_str
(the inherent projection on a single Value handle) nor with
serde_json::Value::get (the inherent slot-lookup returning
Option<&Value>). A name collision would let a caller who has
ValueGetExt in scope resolve to one of the inherent methods
by accident (inherent methods win over trait methods in method
resolution) and silently drop half of the paired chain. The
get_str(<key>) shape names the intent: look up the slot at
<key>, project the returned handle to &str, in ONE call.
§#[must_use]
Every pre-lift consumer binds the returned Option<&str> into
a downstream .ok_or_else(...)?.to_string() / .map(String::from)
/ .map(str::to_string) / pattern-match arm. Dropping the
return silently discards the projection entirely, which is
never the intended semantic at any of the seven pre-lift
consumers.
§Return lifetime
The &str borrows the same buffer the underlying
Value::String variant owns; the Option<&str> is bounded by
the receiver’s lifetime (&'_ self), so a caller holding onto
the returned slice keeps the receiver borrowed. Matches the
pre-lift chain’s own borrow shape (v.as_str() borrows through
the &Value).
A future normalization on the projection — a Unicode
normalization pass (NFC-folding annotation values), a
per-fleet trim of leading/trailing whitespace, a rejection of
empty-string arms as “the caller meant absent” — lands at THIS
ONE substrate primitive and every downstream apiVersion /
kind / metadata.name / K8s-condition-string reader
inherits the upgrade mechanically.
Theory anchor: THEORY.md §VI.1 (generation over composition —
the two-link .get(<key>).and_then(|v| v.as_str()) chain
recurred at SEVEN production sites across two crates past the
★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to
ONE substrate owner here on the string axis of the same
READ-chain axis-family the get_i64 sibling opened for the
integer axis). THEORY.md §II.1 invariant 5 (composition
preserves proofs — a regression that drifted the projection
axis at ONE site would silently pass every downstream
composition and surface as a wrong slot at operator-facing
diagnostic wording; post-lift the projection lives at ONE
typed owner so a regression surfaces at
[tests::get_str_present_string_slot_returns_the_slice] /
peers rather than as silent operator-facing drift).
Sourcefn get_array(&self, key: &str) -> Option<&Vec<Value>>
fn get_array(&self, key: &str) -> Option<&Vec<Value>>
Look up key on this JSON object and project the returned
handle to &Vec<Value>; returns None when the slot is
absent, when the receiver is not a JSON object, or when the
slot’s variant is not Value::Array.
Array-axis sibling of Self::get_i64 + Self::get_str
on the same .get(<key>).and_then(|v| v.as_<T>()) READ-chain
axis-family. Pre-lift the two-link chain was hand-authored at
TWO production sites across two crates past the ★★
PRIME-DIRECTIVE ≥ 2 duplication threshold:
tatara-reconciler::ssapply::ready_condition_value— the tail of thedata.get("status").and_then(|s| s.get("conditions")).and_then(|c| c.as_array())walker that opens the K8s Condition classifier every DynamicObject readiness probe rides through.tatara-closed-loop-probe::probe::count_jwks_keys— the JWKS-response walker that counts issuer-side public keys off thekeysslot for the closed-loop probe’s per-runjwks_key_countdiagnostic.
Both sites walked the SAME two-link chain — .get(<key>) on a
serde_json::Value already known to be an object, then
.and_then(|v| v.as_array()) on the returned Option<&Value>
— and composed different downstream tails (Some(conditions)
pattern-match on the reconciler side, .map(|xs| xs.len() as u64) on the probe side). Post-lift each callsite reads
<value>.get_array(<key>) and the two-link READ chain lives at
ONE substrate owner here. The probe-side variant additionally
sheds the pre-lift .get("keys").cloned() allocation because
this primitive borrows through the receiver rather than
cloning.
§Naming — get_array, not as_array or array_at
Same discipline as Self::get_i64 + Self::get_str — the
trait method deliberately does NOT collide with
serde_json::Value::as_array (the inherent projection on a
single Value handle) nor with serde_json::Value::get (the
inherent slot-lookup returning Option<&Value>). A name
collision would let a caller who has ValueGetExt in scope
resolve to one of the inherent methods by accident (inherent
methods win over trait methods in method resolution) and
silently drop half of the paired chain. The get_array(<key>)
shape names the intent: look up the slot at <key>, project
the returned handle to &Vec<Value>, in ONE call.
§#[must_use]
Every pre-lift consumer binds the returned Option<&Vec<Value>>
into a downstream let Some(...) = ... else { return ... }
short-circuit or a .map(|xs| xs.len() as u64).unwrap_or(0)
counter composition. Dropping the return silently discards the
projection entirely, which is never the intended semantic at
either pre-lift consumer.
§Return lifetime
The &Vec<Value> borrows the same buffer the underlying
Value::Array variant owns; the Option<&Vec<Value>> is
bounded by the receiver’s lifetime (&'_ self), so a caller
iterating the returned slice keeps the receiver borrowed.
Matches the pre-lift chain’s own borrow shape (v.as_array()
borrows through the &Value), and in the probe.rs case
eliminates the pre-lift .cloned() on the intermediate
Value that only existed to sidestep the borrow.
A future normalization on the projection — a rejection of
empty arrays as “the caller meant absent”, an accept-scalar
coercion (a Value::String promoted to a one-element array),
a per-fleet cap on array length that short-circuits pathological
payloads — lands at THIS ONE substrate primitive and every
downstream K8s-Condition classifier / JWKS-array counter /
future array-slot reader inherits the upgrade mechanically.
Theory anchor: THEORY.md §VI.1 (generation over composition —
the two-link .get(<key>).and_then(|v| v.as_array()) chain
recurred at two production sites across two crates past the ★★
PRIME-DIRECTIVE ≥ 2 duplication trigger, and is lifted to ONE
substrate owner here on the array axis of the same READ-chain
axis-family the get_i64 + get_str siblings already own).
THEORY.md §II.1 invariant 5 (composition preserves proofs — a
regression that drifted the projection axis at ONE site would
silently pass every downstream composition and surface as a
wrong slot at operator-facing diagnostic wording; post-lift the
projection lives at ONE typed owner so a regression surfaces
at [tests::get_array_present_array_slot_returns_the_slice] /
peers rather than as silent operator-facing drift).
Sourcefn get_bool(&self, key: &str) -> Option<bool>
fn get_bool(&self, key: &str) -> Option<bool>
Look up key on this JSON object and project the returned
handle to bool; returns None when the slot is absent, when
the receiver is not a JSON object, or when the slot’s variant
is not Value::Bool.
Boolean-axis sibling of Self::get_i64 / Self::get_str /
Self::get_array on the same .get(<key>).and_then(|v| v.as_<T>()) READ-chain axis-family. Completes the axis-family
coverage over the four most-common Value scalar / collection
shapes an operator reads out of a K8s status / spec blob or a
rendered-resource JSON object: integer counter (succeeded,
failed, active, replicas), string slot (apiVersion,
kind, metadata.name, type, status, message), array
slot (conditions, finalizers, keys), and boolean flag
(controller, blockOwnerDeletion, spec.suspended,
hostNetwork, automountServiceAccountToken,
identity.name_override).
The axis was named as the next extension point in the
ValueGetExt docstring’s own guidance (“Adding a new axis
(a get_bool for Value::Bool, …) lands as ONE new method
here + ONE impl arm”), and this method opens it. A future
consumer walking a blockOwnerDeletion / controller bit off
a K8s OwnerReference JSON, an identity.name_override flag off
a phase_status_with(phase, "identity", …) patch body, or a
spec.suspended gate off a SIGSTOP-driven spec toggle reaches
this substrate rather than re-authoring the two-link
.get(<key>).and_then(|v| v.as_bool()) chain by hand.
§Naming — get_bool, not as_bool or bool_at
Same discipline as the three sibling axes — the trait method
deliberately does NOT collide with serde_json::Value::as_bool
(the inherent projection on a single Value handle) nor with
serde_json::Value::get (the inherent slot-lookup returning
Option<&Value>). A name collision would let a caller who has
ValueGetExt in scope resolve to one of the inherent methods
by accident (inherent methods win over trait methods in method
resolution) and silently drop half of the paired chain. The
get_bool(<key>) shape names the intent: look up the slot at
<key>, project the returned handle to bool, in ONE call.
§#[must_use]
Every consumer either binds the returned Option<bool> into a
downstream if let Some(b) = ... gate, a
.unwrap_or_default() / .unwrap_or(false) fallback, or a
pattern-match arm. Dropping the return silently discards the
projection entirely, which is never the intended semantic at
any downstream boolean-flag consumer.
§Composability
- Key slot is
&str— matches the sibling axes verbatim;&'static strliterals and runtime-composedStringhandles both coerce. - Returns
Option<bool>matching the composed inherent chain’s own return; a consumer wanting the “absent or non-bool → false” fallback composes.unwrap_or_default()(or.unwrap_or(false)) at the callsite, keeping the “should this flag default to false or fail loud” decision at the caller rather than baking it into the primitive. - Non-object receivers (a
Value::String, aValue::Null) returnNoneverbatim via the inherentValue::get’s own non-object-arm behaviour, matching the pre-lift chain’s semantics on the corner where the caller’s status blob is malformed.
A future normalization on the projection — a stricter
Value::String("true") / Value::String("false") coercion for
K8s wire-form drift (K8s occasionally serialises booleans as
stringified values in edge cases), a per-fleet default policy
for the absent-slot corner, a checked corner that fails loud
on Value::Number(0) / Value::Number(1) coercion attempts —
lands at THIS ONE substrate primitive and every downstream
boolean-flag reader inherits the upgrade mechanically. No
per-site edit at any consumer that adopts this primitive.
Theory anchor: THEORY.md §II.1 invariant 5 (composition
preserves proofs — the projection lives at ONE typed owner on
the same axis-family the three sibling axes already open; a
regression that drifted the projection axis at ONE site would
silently pass every downstream composition and surface as a
wrong flag at operator-facing gate wording). THEORY.md §III
(typescape — the axis-family completes coverage over the four
most-common Value shapes any K8s status / spec / manifest
reader projects, so a caller who imports ValueGetExt reaches
integer counters, string slots, array slots, AND boolean
flags through ONE trait handle).
Dyn Compatibility§
This trait is dyn compatible.
In older versions of Rust, dyn compatibility was called "object safety".
Implementations on Foreign Types§
Source§impl ValueGetExt for Map<String, Value>
Receiver-shape widening of the READ-projection axis-family — the
same three methods extended from Value (the Value::Object arm’s
walker) to Map<String, Value> (the object interior itself),
closing the receiver-shape gap so a caller who already holds an
&Map<String, Value> handle (via .as_object().unwrap(), via
ValueObjectExt::as_object_mut_or, via the two JsonMap*Ext
siblings’ returns, or via a helper like ssapply::ownership_kv_pair
that composes and returns a Map directly) reaches the SAME
get_i64 / get_str / get_array methods without a
Value::Object(m) rewrap detour.
impl ValueGetExt for Map<String, Value>
Receiver-shape widening of the READ-projection axis-family — the
same three methods extended from Value (the Value::Object arm’s
walker) to Map<String, Value> (the object interior itself),
closing the receiver-shape gap so a caller who already holds an
&Map<String, Value> handle (via .as_object().unwrap(), via
ValueObjectExt::as_object_mut_or, via the two JsonMap*Ext
siblings’ returns, or via a helper like ssapply::ownership_kv_pair
that composes and returns a Map directly) reaches the SAME
get_i64 / get_str / get_array methods without a
Value::Object(m) rewrap detour.
Pre-lift the .get(<key>).and_then(Value::as_<T>) two-link chain
was hand-authored at 30 Map<String, Value>-receiver sites past the
★★ PRIME-DIRECTIVE ≥ 2 duplication threshold — 19 in
tatara-reconciler::patch tests (the phase-status wire-shape pins
walking obj = v.as_object().unwrap() and metadata = obj.get( "metadata").and_then(Value::as_object).unwrap() receivers) plus
11 in tatara-reconciler::ssapply tests (the ownership-tag +
composed-coord pins walking the Map handles returned by
ownership_annotations / ownership_labels /
ownership_annotations_by_coord). Post-lift each callsite reads
<map>.get_str(<key>) / <map>.get_array(<key>) and the READ
chain rides through the SAME substrate owner the Value-receiver
callers already threaded through.
The axis-family invariant (a caller who imports ValueGetExt
reaches every axis through the same trait handle — pinned at
[tests::get_array_axis_family_reaches_i64_str_and_array_through_one_trait_import],
its get_str sibling, and the fourth-axis sibling
[tests::get_bool_axis_family_reaches_i64_str_array_and_bool_through_one_trait_import])
extends verbatim to the Map receiver: a single
use tatara_process::json_object::ValueGetExt; unlocks every
axis on both receiver shapes. A future new axis (e.g. a
get_object for Value::Object slots, a get_f64 for
Value::Number truncated to f64) adds one method on the trait
and inherits both impls; there is no separate MapGetExt peer to
keep in sync.
Theory anchor: THEORY.md §VI.1 (generation over composition — the
two-link chain recurred at 30 Map-receiver sites past the ★★
PRIME-DIRECTIVE ≥ 2 duplication trigger, and rides through the
same substrate owner the pre-existing Value-receiver impl above
already pinned). THEORY.md §II.1 invariant 5 (composition preserves
proofs — the receiver-shape widening carries the axis-family
invariant across without splitting it into two traits).
Source§impl ValueGetExt for Option<&Value>
Receiver-shape widening of the READ-projection axis-family — the
same four methods extended from Value / Map<String, Value> to
Option<&Value>, closing the outer-optionality gap so a caller who
has already threaded an inherent Value::get(<key>) → Option<&Value>
walk into a nested slot (or otherwise holds an Option<&Value> from
a prior projection) reaches the SAME get_i64 / get_str /
get_array / get_bool methods through the SAME trait handle
without an intermediate .and_then(|v| v.get_<T>(<key>)) closure.
impl ValueGetExt for Option<&Value>
Receiver-shape widening of the READ-projection axis-family — the
same four methods extended from Value / Map<String, Value> to
Option<&Value>, closing the outer-optionality gap so a caller who
has already threaded an inherent Value::get(<key>) → Option<&Value>
walk into a nested slot (or otherwise holds an Option<&Value> from
a prior projection) reaches the SAME get_i64 / get_str /
get_array / get_bool methods through the SAME trait handle
without an intermediate .and_then(|v| v.get_<T>(<key>)) closure.
Pre-lift the <opt>.and_then(|v| v.get_<T>(<key>)) outer-
optionality closure was hand-authored at THREE production callsites
past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold:
tatara-process::status::RenderedResourceCoords::from_json— themetadata.and_then(|m| m.get_str("namespace"))walk that projects the optionalmetadata.namespaceslot off a rendered manifest’smetadatahandle (metadata: Option<&Value>, since a K8s manifest MAY omit themetadataslot altogether — a cluster- scoped resource, a template-authored intermediate spec).tatara-process::status::RenderedResourceCoords::required_str— the private required-extract helper’sv.and_then(|x| x.get_str(key))walk (v: Option<&Value>), the sink everyapiVersion/kind/metadata.namerequired extract fans through.tatara-reconciler::ssapply::ready_condition_value— thedata.get("status").and_then(|s| s.get_array("conditions"))walk that opens the K8s Condition classifier every DynamicObject readiness probe rides through; the outerOption<&Value>comes from the inherentValue::get("status")step.
All three sites walked the SAME .and_then(|<v>| <v>.get_<T> (<key>)) closure shape, differing only in the axis (get_str at
two sites, get_array at the third), the slot name, and the
closure-argument binding. Post-lift each callsite reads <opt> .get_<T>(<key>) and the outer-optionality unwrap-then-project
lives at ONE substrate owner here — the closure disappears, the
method-call surface stays identical to the two pre-existing
receiver-shape impls.
§Composability
- Chains directly off an inherent
Value::get(<key>)step —data.get("status").get_array("conditions")reads as one left-to-right walk, no nested closure. - Composes bytewise with the pre-lift
.and_then(|v| v.get_<T> (<key>))chain — the impl body IS(*self).and_then(|v| v.get_<T>(key)), so the returnedOptionis bit-for-bit what the pre-lift closure produced. Option<&Value>isCopy(every&TisCopy, soOption<&Value>: Copy), so the(*self)deref inside the impl is a bare bitwise copy — no clone, no additional allocation.
§Return lifetime
The returned Option<&str> / Option<&Vec<Value>> borrows through
the underlying &Value handle that lived inside the outer Option;
the lifetime is bounded by &self (elided per the trait method
signatures), matching the two pre-existing receiver impls. A caller
that consumes the borrow before the outer Option<&Value> handle
expires sees no observable difference in borrow scope from the
pre-lift .and_then(|v| v.get_<T>(<key>)) chain.
§Axis-family invariant
The axis-family invariant carries verbatim from the two pre-existing
impls: a single use tatara_process::json_object::ValueGetExt;
unlocks every axis (get_i64 / get_str / get_array / get_bool)
on all three receiver shapes (Value, Map<String, Value>,
Option<&Value>). A future new axis (get_object for
Value::Object slots, get_f64 for Value::Number truncated to
f64) adds ONE method on the trait and inherits all three impls;
there is no separate OptGetExt peer to keep in sync. Pinned at
[tests::option_ref_value_axis_family_reaches_all_four_axes_through_one_trait_import].
Theory anchor: THEORY.md §VI.1 (generation over composition — the
outer-optionality .and_then(|v| v.get_<T>(<key>)) closure recurred
at three production sites past the ★★ PRIME-DIRECTIVE ≥ 2
duplication trigger, and is lifted to ONE substrate owner here).
THEORY.md §II.1 invariant 5 (composition preserves proofs — the
receiver-shape widening carries the axis-family invariant across
without splitting it into two traits; a regression that specialised
one axis at ONE receiver but not the other would silently split the
three receiver shapes’ behaviour and break the “widening preserves
semantics” invariant at
[tests::option_ref_value_get_str_matches_value_arm_bytewise_when_some]
and its per-axis peers).