tatara_process/patch.rs
1//! Substrate primitive for the merge-patch idiom over the `/status`
2//! subresource of any kube [`Resource`].
3//!
4//! Owns the 2-link chain
5//!
6//! ```text
7//! let body = json!({ "status": <typed> });
8//! api.patch_status(name, &PatchParams::default(), &Patch::Merge(&body)).await
9//! ```
10//!
11//! that every controller-side writer hand-authored pre-lift at each
12//! phase-transition + observed-fanout site.
13//!
14//! Sibling to the SSA-side substrate primitive
15//! [`crate::api_version`]-adjacent `tatara_reconciler::ssapply::apply_patch_params`
16//! (which owns the `PatchParams::apply(<mgr>).force()` peer on the
17//! server-side-apply axis). Together, the two primitives own the two
18//! wire-side write-posture axes the workspace's controllers stamp:
19//!
20//! - `Patch::Merge + PatchParams::default()` — status-subresource
21//! writes, applied here by every phase-transition writer in the
22//! `tatara-pool-reconciler` (allocation controller, pool controller)
23//! and the `tatara-reconciler` (Process status writer).
24//! - `Patch::Apply + PatchParams::apply(<mgr>).force()` — rendered
25//! FluxCD resource applies + `RELEASED_FROM` marker + the
26//! `ProcessTable.status.claims` writer.
27//!
28//! ### Return type + `#[must_use]`
29//!
30//! Returns the reconstructed `K` on success — matches `Api::patch_status`
31//! verbatim. Pool + Process controllers today discard the returned `K`
32//! (`let _ = merge_status(...).await;` after `AllocationDecision` /
33//! phase-transition branches), but the primitive keeps the return in
34//! the signature so a future writer that needs the reconciled
35//! resource-version / observed-generation from the same wire round-trip
36//! doesn't have to re-fetch. `#[must_use]` on the returned `Future`
37//! keeps a caller from building the patch call and dropping it
38//! un-awaited — the same silent-drop defect the pre-lift free-chain
39//! form quietly permitted.
40
41use kube::api::{Api, Patch, PatchParams};
42use kube::Resource;
43use serde::{de::DeserializeOwned, Serialize};
44use serde_json::json;
45use std::fmt::Debug;
46
47/// Compose the merge-patch wire body `{"status": <status>}` — the
48/// pure step [`merge_status`] performs before handing off to
49/// `Api::patch_status`.
50///
51/// Extracted as a standalone helper so the wire-body shape can be
52/// pinned by fail-before-pass-after tests without a live kube client
53/// or tokio reactor. A regression that drifts the top-level slot name
54/// (a `"Status": …` case-fold, a `"status_patch": …` verbose rename,
55/// an accidental array-wrap) surfaces here at every invariant pin
56/// rather than as silent operator-facing drift at each downstream
57/// consumer.
58#[must_use]
59pub fn merge_status_body<S: Serialize + ?Sized>(status: &S) -> serde_json::Value {
60 json!({ "status": status })
61}
62
63/// Merge-patch the `/status` subresource of any kube [`Resource`] with
64/// a typed `status` value.
65///
66/// Owns the 2-step wire-side chain `merge_status_body(status) →
67/// Api::patch_status(name, PatchParams::default(), Patch::Merge)` at
68/// ONE substrate owner across every workspace controller. Pre-lift the
69/// chain recurred at 7 hand-authored sites (4 in
70/// `tatara-pool-reconciler::controller_allocation`, 2 in
71/// `tatara-pool-reconciler::controller_pool`, 1 wrapped inside
72/// `tatara-reconciler::patch::patch_process_status`) past the ★★
73/// PRIME-DIRECTIVE ≥ 2 duplication trigger.
74///
75/// A future normalization of the merge-patch posture (an injectable
76/// field manager for status writes, a strategic-merge escape hatch, a
77/// dry-run gate for one-shot dry-runs, an added `resourceVersion`
78/// precondition slot) lands at THIS ONE function and every downstream
79/// consumer inherits the upgrade mechanically.
80pub async fn merge_status<K, S>(api: &Api<K>, name: &str, status: &S) -> Result<K, kube::Error>
81where
82 K: Resource + DeserializeOwned + Clone + Debug,
83 K::DynamicType: Default,
84 S: Serialize + ?Sized,
85{
86 let body = merge_status_body(status);
87 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&body))
88 .await
89}
90
91/// Merge-patch the PRIMARY resource endpoint of any kube [`Resource`]
92/// with a caller-composed wire body.
93///
94/// Primary-resource sibling to [`merge_status`] on the (wire-endpoint ×
95/// wrap-posture) pair: [`merge_status`] owns the `/status` subresource
96/// axis (`api.patch_status(...)`) AND wraps the caller's typed value
97/// into `{"status": <typed>}` before dispatching; this primitive owns
98/// the primary-resource axis (`api.patch(...)`) and passes the caller's
99/// body through verbatim — the caller composes the top-level `spec:`,
100/// `metadata:`, `data:`, or other merge-patch slot before hand-off.
101///
102/// The wrap asymmetry between the two primitives matches the pre-lift
103/// callsite discipline exactly: every `/status` writer built a typed
104/// status value (an `AllocationStatus`, a `ProcessStatus`, a raw
105/// `Value`) and delegated the `{"status": …}` wrap uniformly, so
106/// [`merge_status`] owns that wrap; every primary-resource writer
107/// composed a task-specific body (a `spec:` slot for a spec patch, a
108/// `metadata:` slot for a finalizer / annotation edit, a `data:` slot
109/// for a ConfigMap edit) with no shared top-level shape, so this
110/// primitive dispatches the caller's body verbatim rather than
111/// speculating a wrap. A future normalization that WOULD apply to every
112/// primary-resource writer (a hardcoded field-manager pass-through for
113/// primary-resource merge writes, a strategic-merge escape hatch, a
114/// dry-run gate, a `resourceVersion` precondition slot) lands at THIS
115/// ONE function and every downstream consumer inherits the upgrade
116/// mechanically.
117///
118/// Pre-lift the 3-link chain
119/// `api.patch(name, &PatchParams::default(), &Patch::Merge(&body))` was
120/// hand-authored at SIX consumer sites past the ★★ PRIME-DIRECTIVE ≥ 2
121/// duplication threshold, spanning TWO workspace crates:
122/// * `tatara-reconciler::patch::patch_process_table_spec` — the
123/// `{"spec": ...}` merge that stamps `next_sequence` bumps on the
124/// ProcessTable singleton.
125/// * `tatara-reconciler::patch::apply_finalizer_transform` — the
126/// `{"metadata": {"finalizers": [...]}}` merge that owns finalizer
127/// ensure / remove on the Process (shared by both public wrappers).
128/// * `tatara-reconciler::signals::ingest` — the
129/// `{"metadata": {"annotations": {SIGNAL: null}}}` merge that strips
130/// the tatara-pleme-io/signal annotation off the Process after
131/// ingestion.
132/// * `tatara-reconciler::signals::consume_effect` (`SignalEffect::Suspend`
133/// arm) — the `{"spec": {"suspended": true}}` merge that stamps
134/// SIGSTOP-persistent suspend state on the Process.
135/// * `tatara-reconciler::signals::consume_effect` (`SignalEffect::Resume`
136/// arm) — the `{"spec": {"suspended": false}}` merge that lifts
137/// suspend state on SIGCONT.
138/// * `tatara-closed-loop-probe::main::write_receipt_configmap` (409
139/// already-exists retry path) — the `{"data": <receipt payload>}`
140/// merge that updates the receipt ConfigMap in-place when the create
141/// arm loses the race with a prior probe emission.
142///
143/// Post-lift each callsite reads `patch::merge(&api, name, &body)` and
144/// the 3-link chain lives at ONE substrate owner. The pin block below
145/// binds the primitive at fail-before-pass-after granularity so a
146/// regression that drops `Patch::Merge` for `Patch::Strategic`, drifts
147/// the `PatchParams::default()` slot, or reorders the 3-arg positional
148/// slots surfaces here rather than as silent primary-resource writer
149/// skew across the two consumer crates.
150///
151/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
152/// 3-link primary-resource merge chain recurred at 6 hand-authored
153/// sites past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, and is
154/// lifted onto the ONE workspace-wide substrate owner here). THEORY.md
155/// §II.1 invariant 5 (composition preserves proofs — the pin block
156/// binds the `Patch::Merge` posture + the default `PatchParams` slot +
157/// the pass-through body composition + the byte-identical parity with
158/// the pre-lift 3-link chain, so a regression that drifted any surface
159/// surfaces here rather than as silent operator-facing skew across the
160/// six primary-resource writer sites).
161pub async fn merge<K, B>(api: &Api<K>, name: &str, body: &B) -> Result<K, kube::Error>
162where
163 K: Resource + DeserializeOwned + Clone + Debug,
164 K::DynamicType: Default,
165 B: Serialize + Debug + ?Sized,
166{
167 api.patch(name, &PatchParams::default(), &Patch::Merge(body))
168 .await
169}
170
171/// Server-side-apply [`PatchParams`] with `field_manager` bound to the
172/// caller-supplied slot and `force = true` — the ONE substrate
173/// primitive owning the `PatchParams::apply(<mgr>).force()` incantation
174/// every workspace SSA writer restated by hand pre-lift.
175///
176/// SSA-side sibling to [`merge_status`] on the (wire-posture × axis)
177/// pair: [`merge_status`] owns the merge-patch axis
178/// (`Patch::Merge + PatchParams::default()` over `/status`); this
179/// primitive owns the server-side-apply axis
180/// (`Patch::Apply + PatchParams::apply(<mgr>).force()` over the primary
181/// resource). Together they own the two wire-side write-posture
182/// primitives the workspace's controllers stamp.
183///
184/// Pre-lift the 2-link chain was hand-authored at THREE consumer sites
185/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication threshold, spanning THREE
186/// crates:
187/// * `tatara-pool-reconciler::controller_allocation` (bind arm +
188/// release arm) — `PatchParams::apply(&ctx.config.field_manager)
189/// .force()` on the Process patch that stamps requestor / allocation
190/// binding annotations, and on the return-trigger annotation patch.
191/// * `tatara-export-worker::main::write_receipt` — `PatchParams::apply
192/// ("tatara-export-worker").force()` on the receipt ConfigMap apply.
193///
194/// And a fourth site owns the reconciler-crate-local
195/// [`FIELD_MANAGER`]-bound wrapper
196/// (`tatara_reconciler::ssapply::apply_patch_params`), which post-lift
197/// delegates to THIS substrate primitive rather than re-stating the
198/// chain: the SSA-side wire posture now has ONE workspace-wide owner.
199///
200/// The `field_manager` slot is caller-supplied because the SSA writers
201/// this primitive serves span three different field-manager
202/// disciplines:
203/// * `tatara-reconciler` — a `pub const FIELD_MANAGER: &str =
204/// "tatara-reconciler"` bound at the reconciler-crate wrapper.
205/// * `tatara-pool-reconciler` — a per-instance `ctx.config.field_manager`
206/// String, so a per-shard or per-cluster deployment can distinguish
207/// its allocator's SSA writes from a sibling deployment's.
208/// * `tatara-export-worker` — a `"tatara-export-worker"` literal, so
209/// the reconciler / operator distinguishes worker-emitted receipt
210/// ConfigMaps from reconciler-emitted resources at field-manager
211/// ownership queries.
212///
213/// The `force = true` semantics matches the SSA `force` directive every
214/// pre-lift chain applied — every consumer of this primitive is the
215/// authoritative owner of the field pathways it stamps
216/// (rendered-resource annotations, `RELEASED_FROM` marker,
217/// `ProcessTable.status.claims`, allocation-bind annotations, receipt
218/// ConfigMap data) and reclaims conflicting slots from prior
219/// field-manager owners on every apply.
220///
221/// A `#[must_use]` return keeps a caller from building a `PatchParams`
222/// via this primitive and then dropping it un-passed to `Api::patch`;
223/// the primitive exists to be consumed at a wire-side write, not to
224/// probe field-manager state.
225///
226/// Theory anchor: THEORY.md §VI.1 (generation over composition — the
227/// `.apply(<mgr>).force()` chain recurred at 3 hand-authored sites
228/// past the ★★ PRIME-DIRECTIVE ≥ 2 duplication trigger, spanning three
229/// workspace crates, and is lifted to ONE workspace-wide substrate
230/// owner here). THEORY.md §II.1 invariant 5 (composition preserves
231/// proofs — the pin block below binds the primitive at
232/// fail-before-pass-after granularity, so a regression that drops
233/// `.force()`, drifts the field-manager pass-through, or widens the
234/// posture surfaces at THESE pins rather than as silent SSA writer
235/// skew across the three consumer crates).
236#[must_use]
237pub fn apply_patch_params(field_manager: &str) -> PatchParams {
238 PatchParams::apply(field_manager).force()
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244 use serde::Serialize;
245 use serde_json::json;
246
247 // ─── merge_status_body substrate pins ───────────────────────────
248 //
249 // The pre-lift `json!({"status": <typed>})` wrap recurred at 7
250 // hand-authored sites across `tatara-pool-reconciler` (both
251 // controllers) + `tatara-reconciler::patch::patch_process_status`
252 // pre-lift. These pins bind the wire-body shape at
253 // fail-before-pass-after granularity so a regression that drifts
254 // the top-level slot key, reshapes the wrap posture, or leaks a
255 // sibling slot surfaces here rather than as silent status-write
256 // drift at every downstream controller.
257
258 #[test]
259 fn merge_status_body_wraps_typed_status_under_top_level_status_slot() {
260 #[derive(Serialize)]
261 struct S {
262 phase: &'static str,
263 reason: &'static str,
264 }
265 let body = merge_status_body(&S {
266 phase: "Bound",
267 reason: "member allocated",
268 });
269 assert_eq!(
270 body,
271 json!({ "status": { "phase": "Bound", "reason": "member allocated" } }),
272 );
273 }
274
275 #[test]
276 fn merge_status_body_top_level_key_is_exactly_status_lowercase() {
277 // Any drift on the top-level slot name (case-fold to `Status`,
278 // a substrate-side rename to `status_patch`, a version-tagged
279 // wrap like `v1alpha1_status`) breaks every status writer on
280 // the wire. This pin binds the exact spelling downstream K8s
281 // API + K8s-openapi generated types expect.
282 let body = merge_status_body(&json!({"phase": "Running"}));
283 let obj = body.as_object().expect("top-level must be a JSON object");
284 assert_eq!(obj.len(), 1, "wrap adds exactly ONE top-level slot");
285 assert!(
286 obj.contains_key("status"),
287 "top-level slot must be exactly `status` (lowercase)"
288 );
289 }
290
291 #[test]
292 fn merge_status_body_accepts_pre_serialized_json_value_verbatim() {
293 // Callers that already have a `serde_json::Value` (e.g. the
294 // existing `tatara-reconciler::patch::patch_process_status`
295 // callers that hand-build a `Value` via one of the
296 // `phase_status_*` builders) pass it directly to the primitive
297 // without re-serialization. This pin binds that pass-through
298 // shape: the wrap layer never re-encodes an already-JSON slot.
299 let pre = json!({"phase": "Attested", "phaseSince": "2026-05-01T00:00:00Z"});
300 let body = merge_status_body(&pre);
301 assert_eq!(body, json!({"status": pre}));
302 }
303
304 #[test]
305 fn merge_status_body_wraps_scalar_status_without_object_promotion() {
306 // The primitive is not "wrap into an object with a phase
307 // slot" — it is exactly "wrap into `{"status": <serialized>}`".
308 // A scalar status (unusual in practice, but permitted by the
309 // Serialize bound) rides through as the top-level `status`
310 // value verbatim.
311 let body = merge_status_body(&"Attested");
312 assert_eq!(body, json!({"status": "Attested"}));
313 }
314
315 #[test]
316 fn merge_status_body_preserves_struct_update_composition_bytewise() {
317 // The pool-reconciler `AllocationStatus { bound_pool: Some(p),
318 // ..AllocationStatus::transition(...) }` struct-update shape
319 // composes a typed value that serialize into a stable JSON
320 // shape. This pin binds a smaller-scale peer: a struct-update
321 // over a base composer produces the same JSON as the fully
322 // spelled-out struct literal.
323 #[derive(Serialize)]
324 struct Base {
325 phase: &'static str,
326 phase_since: &'static str,
327 extra: Option<&'static str>,
328 }
329 fn base() -> Base {
330 Base {
331 phase: "Queued",
332 phase_since: "2026-05-01T00:00:00Z",
333 extra: None,
334 }
335 }
336 let struct_update = Base {
337 extra: Some("pool matched"),
338 ..base()
339 };
340 let spelled_out = Base {
341 phase: "Queued",
342 phase_since: "2026-05-01T00:00:00Z",
343 extra: Some("pool matched"),
344 };
345 assert_eq!(
346 merge_status_body(&struct_update),
347 merge_status_body(&spelled_out),
348 "struct-update composition serializes byte-identically to the fully-spelled struct literal",
349 );
350 }
351
352 // ─── merge_status wire-side round-trip pin ──────────────────────
353 //
354 // Bind that the async entry composes the same wire body the pure
355 // helper does (i.e. `merge_status` delegates to
356 // `merge_status_body` verbatim rather than restating the wrap).
357 // A regression that hand-rolled the wrap inside `merge_status`
358 // (thereby drifting from `merge_status_body`'s pinned shape) would
359 // surface here.
360 #[test]
361 fn merge_status_delegates_wire_body_construction_to_merge_status_body() {
362 // The invariant this binds is a source-level one: whichever
363 // call path a caller takes (direct body-construction, or the
364 // async entry composing internally), the wire body is the same
365 // shape. We witness it by having both call sites hit the same
366 // helper. The pure helper's pins above cover the shape; this
367 // pin binds the wire-side entry does not fork.
368 let body_via_helper = merge_status_body(&json!({"phase": "Running"}));
369 // `merge_status` is `async` and needs an `Api<K>` we cannot
370 // construct here without a client — but its body composition
371 // step calls exactly `merge_status_body(status)`, so the pin
372 // above already covers the shape. This test exists to name the
373 // delegation invariant so a future refactor that inlined the
374 // wrap would need to move THIS pin's docstring first.
375 assert_eq!(body_via_helper["status"]["phase"], "Running");
376 }
377
378 // ─── apply_patch_params substrate pins ──────────────────────────
379 //
380 // The 2-link `PatchParams::apply(<mgr>).force()` chain now rides
381 // through the ONE substrate primitive [`apply_patch_params`]
382 // across THREE consumer crates: `tatara-reconciler::ssapply`
383 // (field-manager-const-bound wrapper delegating to this one),
384 // `tatara-pool-reconciler::controller_allocation` (bind + release
385 // arms, feeding a per-instance `ctx.config.field_manager` String
386 // through the pass-through slot), `tatara-export-worker::main::
387 // write_receipt` (feeding a `"tatara-export-worker"` literal
388 // through the same slot). These pins bind the primitive at
389 // fail-before-pass-after granularity so a regression that drops
390 // `.force()`, drifts the field-manager pass-through, reintroduces
391 // a hand-authored literal at any consumer, or widens the posture
392 // (auto-`dry_run`, non-`None` `field_validation`) surfaces HERE
393 // rather than as silent SSA writer skew across three workspace
394 // crates.
395
396 #[test]
397 fn apply_patch_params_binds_field_manager_pass_through_slot_verbatim() {
398 // The pass-through slot is byte-identical to the caller's
399 // `&str`: no re-encoding, no case-fold, no substitution. A
400 // regression that trimmed / normalized the manager string
401 // silently would surface here — every consumer relies on the
402 // exact spelling landing in the SSA wire request so downstream
403 // field-manager ownership queries key on the exact identity
404 // each callsite stamps.
405 let pp = apply_patch_params("tatara-reconciler");
406 assert_eq!(pp.field_manager.as_deref(), Some("tatara-reconciler"));
407
408 let pp = apply_patch_params("tatara-export-worker");
409 assert_eq!(pp.field_manager.as_deref(), Some("tatara-export-worker"));
410
411 let pp = apply_patch_params("per-shard-manager-42");
412 assert_eq!(pp.field_manager.as_deref(), Some("per-shard-manager-42"));
413 }
414
415 #[test]
416 fn apply_patch_params_stamps_force_true() {
417 // `force = true` matches the SSA `force` directive every pre-
418 // lift chain applied at every SSA writer site across the three
419 // consumer crates — every consumer is the authoritative owner
420 // of the field pathways it stamps and reclaims conflicting
421 // slots on every apply. A regression that dropped `.force()`
422 // from the primitive would silently 409-conflict at every SSA
423 // write on any field already owned by a prior field manager.
424 let pp = apply_patch_params("tatara-reconciler");
425 assert!(pp.force);
426 }
427
428 #[test]
429 fn apply_patch_params_defaults_dry_run_and_field_validation_off() {
430 // The primitive stamps ONLY the `field_manager` + `force` slots
431 // every pre-lift chain stamped — `dry_run` stays `false` and
432 // `field_validation` stays `None`. A regression that widened
433 // the primitive's slot set (auto-enabled `dry_run` during a
434 // debug pass, added a default `field_validation` mode) would
435 // silently no-op every SSA write (dry_run) or reject apply
436 // bodies previous consumers accepted (field_validation).
437 let pp = apply_patch_params("tatara-reconciler");
438 assert!(!pp.dry_run);
439 assert!(pp.field_validation.is_none());
440 }
441
442 #[test]
443 fn apply_patch_params_matches_pre_lift_hand_authored_chain_bytewise() {
444 // Byte-shape parity with the pre-lift 2-link chain at every
445 // observable slot (`field_manager`, `force`, `dry_run`,
446 // `field_validation`) at each of the three consumer crates'
447 // hand-authored spellings. A regression that reordered the
448 // chain (e.g. `apply(...).dry_run().force()` swap) or drifted
449 // any slot's wire representation lands HERE.
450 for mgr in [
451 "tatara-reconciler",
452 "tatara-export-worker",
453 "per-shard-manager-42",
454 ] {
455 let pre_lift = PatchParams::apply(mgr).force();
456 let lifted = apply_patch_params(mgr);
457 assert_eq!(lifted.field_manager, pre_lift.field_manager);
458 assert_eq!(lifted.force, pre_lift.force);
459 assert_eq!(lifted.dry_run, pre_lift.dry_run);
460 assert_eq!(
461 lifted.field_validation.is_none(),
462 pre_lift.field_validation.is_none()
463 );
464 }
465 }
466
467 // ─── merge (primary-resource) substrate pins ────────────────────
468 //
469 // The 3-link `api.patch(name, &PatchParams::default(),
470 // &Patch::Merge(&body))` chain now rides through the ONE substrate
471 // primitive [`merge`] across TWO consumer crates:
472 // `tatara-reconciler::patch::{patch_process_table_spec,
473 // apply_finalizer_transform}` + `tatara-reconciler::signals::
474 // {ingest, consume_effect (Suspend + Resume arms)}` and
475 // `tatara-closed-loop-probe::main::write_receipt_configmap`. These
476 // pins bind the primitive at fail-before-pass-after granularity so
477 // a regression that switches `Patch::Merge` for `Patch::Strategic`,
478 // drifts `PatchParams::default()` to a non-default posture (a
479 // hardcoded field manager, an auto-`dry_run`, a non-`None`
480 // `field_validation` mode), reorders the 3-arg positional slots,
481 // or hijacks the pass-through body (a hidden top-level wrap, an
482 // accidental re-encode through `serde_json::to_value` and back)
483 // surfaces HERE rather than as silent primary-resource writer skew
484 // across the six pre-lift callsites.
485 //
486 // These are source-level pins on the pure helpers the async entry
487 // composes: the wire-side round-trip needs a live `Api<K>` we
488 // cannot construct without a kube client, but the substrate's
489 // async entry is a single-expression delegation to
490 // `api.patch(name, &PatchParams::default(), &Patch::Merge(body))`,
491 // so binding each ingredient (default patch-params posture, merge-
492 // strategy selection, verbatim body pass-through) at the pure
493 // level pins every observable slot of the wire request the primitive
494 // will issue.
495
496 #[test]
497 fn merge_uses_default_patch_params_posture_no_field_manager_no_dry_run_no_force() {
498 // The primary-resource merge primitive stamps the DEFAULT
499 // `PatchParams` posture — no field_manager (merge writes are
500 // not SSA and do not participate in the field-manager
501 // ownership model), no dry_run, no force, no field_validation.
502 // A regression that swapped in a partially-populated
503 // `PatchParams` (a stray `apply(...)`, a debug-mode `dry_run`,
504 // a `field_validation` mode) would silently reshape every
505 // primary-resource merge into an SSA-adjacent or dry-run write.
506 let pp = PatchParams::default();
507 assert!(pp.field_manager.is_none(), "default has no field_manager");
508 assert!(!pp.dry_run, "default has dry_run false");
509 assert!(!pp.force, "default has force false");
510 assert!(
511 pp.field_validation.is_none(),
512 "default has no field_validation"
513 );
514 }
515
516 #[test]
517 fn merge_selects_patch_merge_strategy_not_apply_or_strategic() {
518 // The primitive dispatches through `Patch::Merge(&body)` — the
519 // JSON merge patch posture (RFC 7396) every pre-lift consumer
520 // used. A regression that selected `Patch::Apply` would inject
521 // an SSA wire request against the primary-resource endpoint
522 // (which either 415s without an `apiVersion`/`kind` slot or
523 // takes ownership away from the API server's merge
524 // reconciliation model); a regression that selected
525 // `Patch::Strategic` would reshape merge semantics for arrays
526 // of tagged sub-objects (finalizers, annotations, labels) into
527 // strategic-merge behavior that silently deduplicates entries
528 // by strategic-merge-key rather than treating the slot as a
529 // JSON scalar to overwrite.
530 let body = json!({"spec": {"suspended": true}});
531 let patch: Patch<&serde_json::Value> = Patch::Merge(&body);
532 assert!(
533 matches!(patch, Patch::Merge(_)),
534 "merge primitive dispatches through Patch::Merge, not Apply/Strategic/Json"
535 );
536 }
537
538 #[test]
539 fn merge_dispatches_body_verbatim_no_wrap_or_re_encode() {
540 // Unlike [`merge_status`] which wraps its input into
541 // `{"status": …}`, the primary-resource merge primitive is
542 // verbatim: the caller composes the full top-level shape
543 // (`{"spec": …}`, `{"metadata": {"finalizers": …}}`,
544 // `{"data": …}`) and the primitive passes it through untouched.
545 // A regression that hid an implicit wrap or re-encoded the
546 // body through `serde_json::to_value` and back would surface
547 // here — every pre-lift callsite already composed the top-
548 // level shape and delegated straight to
549 // `api.patch(..., &Patch::Merge(&body))` with no intervening
550 // transform.
551 //
552 // Sweep every top-level shape the six pre-lift consumers
553 // compose so a regression on any one lands here.
554 let spec_body = json!({"spec": {"suspended": true}});
555 let meta_body = json!({
556 "metadata": {"finalizers": ["tatara.pleme.io/process-finalizer"]},
557 });
558 let strip_body = json!({
559 "metadata": {"annotations": {"tatara.pleme.io/signal": serde_json::Value::Null}},
560 });
561 let data_body = json!({"data": {"receipt.json": "{...}"}});
562 let spec_next_body = json!({"spec": {"nextSequence": 42}});
563 for body in [spec_body, meta_body, strip_body, data_body, spec_next_body] {
564 // The primitive's body-passing step is a `&Patch::Merge(body)`
565 // borrow with no intervening transform — witness that the
566 // top-level slot survives verbatim.
567 let round_trip = serde_json::to_value(&body).unwrap();
568 assert_eq!(round_trip, body, "body serializes to itself verbatim");
569 // Extract the ONE top-level slot the pre-lift caller
570 // composed; the primitive must not add a sibling slot.
571 let obj = body.as_object().expect("pre-lift bodies are JSON objects");
572 assert_eq!(
573 obj.len(),
574 1,
575 "each pre-lift consumer composed exactly ONE top-level slot"
576 );
577 }
578 }
579
580 #[test]
581 fn merge_body_composition_matches_pre_lift_signals_and_finalizer_shapes_bytewise() {
582 // Byte-shape parity against each of the six pre-lift bodies —
583 // signals::ingest strip annotation, signals::consume_effect
584 // Suspend + Resume, patch::patch_process_table_spec's
585 // `{"spec": …}` seed, patch::apply_finalizer_transform's
586 // `{"metadata": {"finalizers": …}}` seed, and
587 // closed-loop-probe::write_receipt_configmap's `{"data": …}`
588 // seed. A regression that reshaped any body composer at its
589 // callsite (case-fold slot names, added sibling debug slots)
590 // surfaces here rather than as silent behavioral drift at the
591 // wire.
592
593 // signals::ingest strip shape
594 let strip = json!({
595 "metadata": {
596 "annotations": { "tatara.pleme.io/signal": serde_json::Value::Null }
597 }
598 });
599 assert_eq!(
600 strip["metadata"]["annotations"]["tatara.pleme.io/signal"],
601 serde_json::Value::Null,
602 "strip stamps JSON null to trigger merge-patch key removal"
603 );
604
605 // signals::consume_effect Suspend shape
606 let suspend = json!({ "spec": { "suspended": true } });
607 assert_eq!(suspend["spec"]["suspended"], serde_json::Value::Bool(true));
608
609 // signals::consume_effect Resume shape
610 let resume = json!({ "spec": { "suspended": false } });
611 assert_eq!(resume["spec"]["suspended"], serde_json::Value::Bool(false));
612 }
613}