Skip to main content

greentic_deploy_spec/engine/
bundles.rs

1//! Pure bundle-deployment verb semantics (Phase D PR-4.2g).
2//!
3//! The bundles verb group (`op bundles add | update | remove`) follows the
4//! PR-4.2a engine contract: pure `&mut Environment` transforms with no I/O,
5//! no clock, and no key material. Both `LocalFsStore` (greentic-deployer,
6//! behind a flock) and the operator-store-server (behind SQLite CAS) drive
7//! the SAME functions, so the duplicate-deployment rule and the remove
8//! live-state guard cannot drift between local and remote.
9//!
10//! # The revenue-policy seam
11//!
12//! `add` and `update --revenue-share` also write a signed, versioned
13//! revenue-policy artifact (B10) — that step needs the operator key and
14//! storage, so it is deliberately NOT here. The transforms return the
15//! deployment's **index** into `Environment.bundles` instead of a clone:
16//! the backend writes the policy artifact from `&env.bundles[idx]` (whose
17//! `revenue_policy_ref` still holds the committed value the version
18//! counter derives from), pins the fresh ref via the same index, and only
19//! then persists. The pure builder both backends share is
20//! `greentic_operator_trust::revenue_policy::build_revenue_policy_version`.
21//!
22//! # Persist rule (read before calling)
23//!
24//! - any `Ok(_)` — the env was mutated; the backend finishes the
25//!   revenue-policy step (where flagged) and persists.
26//! - any `Err(_)` — the env was not mutated; nothing to persist.
27//!
28//! The A8 `Idempotency-Key` is transport metadata only (as in the bindings
29//! group): the engine never sees it, the server echoes it into the audit
30//! record, and replay caching is the PR-4.3 ledger.
31//!
32//! # ID minting
33//!
34//! `add_bundle` takes a pre-minted [`DeploymentId`]: `LocalFsStore` mints
35//! it CLI-side, the operator-store-server mints it handler-side. This
36//! mirrors the PR-3b wire contract, where `POST /bundles` carries no
37//! `deployment_id` — the authority that owns storage mints the ULID.
38//!
39//! # Wire shapes
40//!
41//! [`AddBundlePayload`] / [`UpdateBundlePayload`] double as the A8 request
42//! bodies (`POST /environments/{env}/bundles` and
43//! `PATCH /environments/{env}/bundles/{deployment_id}`);
44//! [`RemoveBundleOutcome`] is the `DELETE` response body. The wire-format
45//! tests at the bottom pin the encoding the PR-3b client established.
46
47use std::collections::BTreeMap;
48use std::path::PathBuf;
49
50use chrono::{DateTime, Utc};
51use serde::{Deserialize, Serialize};
52use serde_json::Value;
53use thiserror::Error;
54
55use crate::bundle_deployment::{
56    BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
57};
58use crate::environment::Environment;
59use crate::ids::{BundleId, CustomerId, DeploymentId, RevisionId};
60use crate::revision::RevisionLifecycle;
61use crate::version::SchemaVersion;
62use greentic_types::EnvId;
63
64// ---------------------------------------------------------------------------
65// Errors
66// ---------------------------------------------------------------------------
67
68/// Why a bundle verb refused to mutate the environment. Display strings are
69/// verbatim what `LocalFsStore` raised before the move (PR-4.2g), so
70/// operator-facing CLI errors are unchanged.
71#[derive(Debug, Clone, PartialEq, Eq, Error)]
72pub enum BundleError {
73    /// P6 anchor (§5.4): one `BundleDeployment` per
74    /// `(env_id, bundle_id, customer_id)`.
75    #[error("bundle `{bundle_id}` for customer `{customer_id}` already deployed in env `{env_id}`")]
76    AlreadyDeployed {
77        bundle_id: BundleId,
78        customer_id: CustomerId,
79        env_id: EnvId,
80    },
81    #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
82    DeploymentNotFound {
83        deployment_id: DeploymentId,
84        env_id: EnvId,
85    },
86    /// The remove live-state guard: any traffic split pointing at the
87    /// deployment, or any non-`Archived` revision under it.
88    #[error(
89        "deployment `{deployment_id}` is still live: {active_splits} traffic split(s), \
90         {active_revisions} non-archived revision(s). Archive revisions and clear the \
91         split first."
92    )]
93    StillLive {
94        deployment_id: DeploymentId,
95        active_splits: usize,
96        active_revisions: usize,
97    },
98}
99
100// ---------------------------------------------------------------------------
101// Wire payloads / outcomes
102// ---------------------------------------------------------------------------
103
104/// Inputs to `EnvironmentMutations::add_bundle`, and the A8
105/// `POST /environments/{env}/bundles` request body.
106///
107/// No `deployment_id` — the storage-owning side mints it (see the module
108/// doc). No `idempotency_key` — the key rides the trait method and the A8
109/// `Idempotency-Key` header, matching every other verb group.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct AddBundlePayload {
112    pub bundle_id: BundleId,
113    pub customer_id: CustomerId,
114    pub revenue_share: Vec<RevenueShareEntry>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub route_binding: Option<RouteBinding>,
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub authorization_ref: Option<String>,
119    #[serde(default)]
120    pub config_overrides: BTreeMap<String, BTreeMap<String, Value>>,
121}
122
123/// Inputs to `EnvironmentMutations::update_bundle`, and the A8
124/// `PATCH /environments/{env}/bundles/{deployment_id}` request body
125/// (`deployment_id` rides in the body too — the server cross-checks it
126/// against the URL segment).
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct UpdateBundlePayload {
129    pub deployment_id: DeploymentId,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub status: Option<BundleDeploymentStatus>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub route_binding: Option<RouteBinding>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub revenue_share: Option<Vec<RevenueShareEntry>>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
138}
139
140/// Outcome of `EnvironmentMutations::remove_bundle`, and the A8 `DELETE`
141/// response body. Surfaces the archived revisions pruned alongside the
142/// deployment so the destructive side effect is explicit on the contract —
143/// the CLI records the IDs in the audit target, and HTTP backends can apply
144/// a separate authorization check against the prune set before committing.
145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct RemoveBundleOutcome {
147    pub deployment: BundleDeployment,
148    /// IDs of revisions removed from `Environment.revisions` as part of the
149    /// post-removal compaction (always in `Archived` state because the
150    /// live-state guard refuses any non-archived revision under the
151    /// deployment).
152    pub pruned_revision_ids: Vec<RevisionId>,
153}
154
155/// What [`update_bundle`] changed, by index into `Environment.bundles`.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub struct BundleUpdateApplied {
158    /// Index of the patched deployment in `Environment.bundles`.
159    pub index: usize,
160    /// `payload.revenue_share` was `Some` — the backend must write a new
161    /// revenue-policy version and pin its ref before persisting. The
162    /// deployment at [`Self::index`] still carries the COMMITTED
163    /// `revenue_policy_ref`, which is exactly what the version counter
164    /// derives from.
165    pub revenue_share_changed: bool,
166}
167
168// ---------------------------------------------------------------------------
169// Transforms
170// ---------------------------------------------------------------------------
171
172/// Append a fresh [`BundleDeployment`] to `env.bundles` and return its
173/// index. Rejects with [`BundleError::AlreadyDeployed`] when
174/// `(bundle_id, customer_id)` is already deployed.
175///
176/// The new deployment's `revenue_policy_ref` is the empty placeholder —
177/// the backend writes the v1 policy artifact from `&env.bundles[idx]` and
178/// pins the resulting ref before persisting (see the module doc).
179pub fn add_bundle(
180    env: &mut Environment,
181    payload: AddBundlePayload,
182    deployment_id: DeploymentId,
183    now: DateTime<Utc>,
184) -> Result<usize, BundleError> {
185    if env
186        .bundles
187        .iter()
188        .any(|b| b.bundle_id == payload.bundle_id && b.customer_id == payload.customer_id)
189    {
190        return Err(BundleError::AlreadyDeployed {
191            bundle_id: payload.bundle_id,
192            customer_id: payload.customer_id,
193            env_id: env.environment_id.clone(),
194        });
195    }
196    let route_binding = payload.route_binding.unwrap_or_else(|| RouteBinding {
197        hosts: Vec::new(),
198        path_prefixes: Vec::new(),
199        tenant_selector: TenantSelector {
200            tenant: "default".to_string(),
201            team: "default".to_string(),
202        },
203    });
204    let authorization_ref = payload
205        .authorization_ref
206        .map(PathBuf::from)
207        .unwrap_or_else(|| PathBuf::from("auth.json"));
208    env.bundles.push(BundleDeployment {
209        schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
210        deployment_id,
211        env_id: env.environment_id.clone(),
212        bundle_id: payload.bundle_id,
213        customer_id: payload.customer_id,
214        status: BundleDeploymentStatus::Active,
215        current_revisions: Vec::new(),
216        route_binding,
217        revenue_share: payload.revenue_share,
218        // Replaced with the v1 policy sidecar path by the backend.
219        revenue_policy_ref: PathBuf::new(),
220        usage: None,
221        created_at: now,
222        authorization_ref,
223        config_overrides: payload.config_overrides,
224    });
225    Ok(env.bundles.len() - 1)
226}
227
228/// Patch a [`BundleDeployment`]'s scalar fields in place. `None` fields are
229/// skipped. Returns the patched index plus whether `revenue_share` changed
230/// (the backend's signal to write a new policy version — see
231/// [`BundleUpdateApplied`]).
232///
233/// Rejects with [`BundleError::DeploymentNotFound`] when `deployment_id`
234/// is absent under the env.
235pub fn update_bundle(
236    env: &mut Environment,
237    payload: UpdateBundlePayload,
238) -> Result<BundleUpdateApplied, BundleError> {
239    let UpdateBundlePayload {
240        deployment_id,
241        status,
242        route_binding,
243        revenue_share,
244        config_overrides,
245    } = payload;
246    let index = env
247        .bundles
248        .iter()
249        .position(|b| b.deployment_id == deployment_id)
250        .ok_or_else(|| BundleError::DeploymentNotFound {
251            deployment_id,
252            env_id: env.environment_id.clone(),
253        })?;
254    if let Some(s) = status {
255        env.bundles[index].status = s;
256    }
257    if let Some(rb) = route_binding {
258        env.bundles[index].route_binding = rb;
259    }
260    if let Some(overrides) = config_overrides {
261        env.bundles[index].config_overrides = overrides;
262    }
263    let revenue_share_changed = revenue_share.is_some();
264    if let Some(shares) = revenue_share {
265        env.bundles[index].revenue_share = shares;
266    }
267    Ok(BundleUpdateApplied {
268        index,
269        revenue_share_changed,
270    })
271}
272
273/// Remove a [`BundleDeployment`] from the env. Refuses with
274/// [`BundleError::StillLive`] if the deployment still carries live state
275/// (any traffic split pointing at it, or any non-`Archived` revision under
276/// it) — callers run `op traffic clear` and archive revisions first. Drops
277/// archived revisions for the same `deployment_id` so the env stays
278/// compact.
279///
280/// Rejects with [`BundleError::DeploymentNotFound`] when the deployment is
281/// absent under the env.
282pub fn remove_bundle(
283    env: &mut Environment,
284    deployment_id: DeploymentId,
285) -> Result<RemoveBundleOutcome, BundleError> {
286    let index = env
287        .bundles
288        .iter()
289        .position(|b| b.deployment_id == deployment_id)
290        .ok_or_else(|| BundleError::DeploymentNotFound {
291            deployment_id,
292            env_id: env.environment_id.clone(),
293        })?;
294    // Live-state guard + prune set computed in one pass over
295    // `env.revisions`. `current_revisions` is plan-level future signal that
296    // A3's stage/warm path does not yet maintain, so it can't be the gate;
297    // the live-state proof is: any traffic split pointing at this
298    // deployment, or any non-`Archived` revision for it.
299    let active_splits = env
300        .traffic_splits
301        .iter()
302        .filter(|s| s.deployment_id == deployment_id)
303        .count();
304    let mut active_revisions = 0usize;
305    let mut pruned_revision_ids: Vec<RevisionId> = Vec::new();
306    for r in env.revisions.iter() {
307        if r.deployment_id != deployment_id {
308            continue;
309        }
310        if matches!(r.lifecycle, RevisionLifecycle::Archived) {
311            pruned_revision_ids.push(r.revision_id);
312        } else {
313            active_revisions += 1;
314        }
315    }
316    if active_splits > 0 || active_revisions > 0 {
317        return Err(BundleError::StillLive {
318            deployment_id,
319            active_splits,
320            active_revisions,
321        });
322    }
323    let deployment = env.bundles.remove(index);
324    env.revisions.retain(|r| r.deployment_id != deployment_id);
325    Ok(RemoveBundleOutcome {
326        deployment,
327        pruned_revision_ids,
328    })
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334    use crate::engine::fresh_environment;
335    use crate::environment::EnvironmentHostConfig;
336    use crate::ids::PartyId;
337    use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
338    use crate::revision::Revision;
339    use crate::traffic_split::TrafficSplit;
340
341    fn env_id() -> EnvId {
342        EnvId::try_from("local").unwrap()
343    }
344
345    fn minimal_env() -> Environment {
346        fresh_environment(
347            &env_id(),
348            "Local".to_string(),
349            EnvironmentHostConfig {
350                env_id: env_id(),
351                region: None,
352                tenant_org_id: None,
353                listen_addr: None,
354                public_base_url: None,
355                gui_enabled: None,
356            },
357            RevocationConfig::default(),
358            RetentionPolicy::default(),
359            HealthStatus::default(),
360        )
361    }
362
363    fn fixed_now() -> DateTime<Utc> {
364        "2026-06-12T00:00:00Z".parse().unwrap()
365    }
366
367    fn shares() -> Vec<RevenueShareEntry> {
368        vec![RevenueShareEntry {
369            party_id: PartyId::new("greentic"),
370            basis_points: 10_000,
371        }]
372    }
373
374    fn add_payload(bundle: &str, customer: &str) -> AddBundlePayload {
375        AddBundlePayload {
376            bundle_id: BundleId::new(bundle),
377            customer_id: CustomerId::new(customer),
378            revenue_share: shares(),
379            route_binding: None,
380            authorization_ref: None,
381            config_overrides: BTreeMap::new(),
382        }
383    }
384
385    fn revision_for(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
386        Revision {
387            schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
388            revision_id: RevisionId::new(),
389            env_id: env_id(),
390            bundle_id: BundleId::new("acme"),
391            deployment_id,
392            sequence: 1,
393            created_at: fixed_now(),
394            bundle_digest: "sha256:deadbeef".to_string(),
395            bundle_source_uri: None,
396            pack_list: Vec::new(),
397            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
398            pack_config_refs: Vec::new(),
399            config_digest: "sha256:cafe".to_string(),
400            signature_sidecar_ref: PathBuf::from("rev.sig"),
401            lifecycle,
402            staged_at: None,
403            warmed_at: None,
404            drain_seconds: 0,
405            abort_metrics: Vec::new(),
406        }
407    }
408
409    fn split_for(deployment_id: DeploymentId) -> TrafficSplit {
410        TrafficSplit {
411            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
412            env_id: env_id(),
413            deployment_id,
414            bundle_id: BundleId::new("acme"),
415            generation: 1,
416            entries: Vec::new(),
417            updated_at: fixed_now(),
418            updated_by: "test".to_string(),
419            idempotency_key: "k".to_string(),
420            authorization_ref: PathBuf::from("auth.json"),
421            previous_split_ref: None,
422        }
423    }
424
425    // --- add ---
426
427    #[test]
428    fn add_bundle_appends_with_defaults_and_empty_policy_ref() {
429        let mut env = minimal_env();
430        let did = DeploymentId::new();
431        let idx = add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now())
432            .expect("fresh pair deploys");
433        assert_eq!(idx, 0);
434        let dep = &env.bundles[0];
435        assert_eq!(dep.deployment_id, did);
436        assert_eq!(dep.status, BundleDeploymentStatus::Active);
437        assert_eq!(dep.created_at, fixed_now());
438        assert_eq!(dep.revenue_policy_ref, PathBuf::new(), "backend pins ref");
439        assert_eq!(dep.authorization_ref, PathBuf::from("auth.json"));
440        assert_eq!(dep.route_binding.tenant_selector.tenant, "default");
441    }
442
443    #[test]
444    fn add_bundle_rejects_duplicate_bundle_customer_pair() {
445        let mut env = minimal_env();
446        add_bundle(
447            &mut env,
448            add_payload("acme", "cust-1"),
449            DeploymentId::new(),
450            fixed_now(),
451        )
452        .unwrap();
453        let err = add_bundle(
454            &mut env,
455            add_payload("acme", "cust-1"),
456            DeploymentId::new(),
457            fixed_now(),
458        )
459        .unwrap_err();
460        assert_eq!(
461            err.to_string(),
462            "bundle `acme` for customer `cust-1` already deployed in env `local`"
463        );
464        assert_eq!(env.bundles.len(), 1, "env untouched on Err");
465    }
466
467    #[test]
468    fn add_bundle_allows_same_bundle_for_different_customer() {
469        let mut env = minimal_env();
470        add_bundle(
471            &mut env,
472            add_payload("acme", "cust-1"),
473            DeploymentId::new(),
474            fixed_now(),
475        )
476        .unwrap();
477        add_bundle(
478            &mut env,
479            add_payload("acme", "cust-2"),
480            DeploymentId::new(),
481            fixed_now(),
482        )
483        .expect("different customer is a distinct deployment");
484        assert_eq!(env.bundles.len(), 2);
485    }
486
487    // --- update ---
488
489    #[test]
490    fn update_bundle_patches_fields_and_flags_revenue_change() {
491        let mut env = minimal_env();
492        let did = DeploymentId::new();
493        add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
494        let applied = update_bundle(
495            &mut env,
496            UpdateBundlePayload {
497                deployment_id: did,
498                status: Some(BundleDeploymentStatus::Paused),
499                route_binding: None,
500                revenue_share: Some(vec![RevenueShareEntry {
501                    party_id: PartyId::new("partner"),
502                    basis_points: 10_000,
503                }]),
504                config_overrides: None,
505            },
506        )
507        .expect("known deployment patches");
508        assert_eq!(applied.index, 0);
509        assert!(applied.revenue_share_changed);
510        assert_eq!(env.bundles[0].status, BundleDeploymentStatus::Paused);
511        assert_eq!(env.bundles[0].revenue_share[0].party_id.as_str(), "partner");
512    }
513
514    #[test]
515    fn update_bundle_without_revenue_share_does_not_flag() {
516        let mut env = minimal_env();
517        let did = DeploymentId::new();
518        add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
519        let applied = update_bundle(
520            &mut env,
521            UpdateBundlePayload {
522                deployment_id: did,
523                status: Some(BundleDeploymentStatus::Paused),
524                route_binding: None,
525                revenue_share: None,
526                config_overrides: None,
527            },
528        )
529        .unwrap();
530        assert!(!applied.revenue_share_changed);
531    }
532
533    #[test]
534    fn update_bundle_rejects_unknown_deployment() {
535        let mut env = minimal_env();
536        let did = DeploymentId::new();
537        let err = update_bundle(
538            &mut env,
539            UpdateBundlePayload {
540                deployment_id: did,
541                status: None,
542                route_binding: None,
543                revenue_share: None,
544                config_overrides: None,
545            },
546        )
547        .unwrap_err();
548        assert_eq!(
549            err.to_string(),
550            format!("deployment `{did}` not found in env `local`")
551        );
552    }
553
554    // --- remove ---
555
556    #[test]
557    fn remove_bundle_prunes_archived_revisions() {
558        let mut env = minimal_env();
559        let did = DeploymentId::new();
560        add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
561        env.revisions
562            .push(revision_for(did, RevisionLifecycle::Archived));
563        env.revisions
564            .push(revision_for(did, RevisionLifecycle::Archived));
565        let other = DeploymentId::new();
566        env.revisions
567            .push(revision_for(other, RevisionLifecycle::Ready));
568
569        let outcome = remove_bundle(&mut env, did).expect("quiesced deployment removes");
570        assert_eq!(outcome.deployment.deployment_id, did);
571        assert_eq!(outcome.pruned_revision_ids.len(), 2);
572        assert!(env.bundles.is_empty());
573        assert_eq!(env.revisions.len(), 1, "other deployment's revision kept");
574        assert_eq!(env.revisions[0].deployment_id, other);
575    }
576
577    #[test]
578    fn remove_bundle_refuses_live_state() {
579        let mut env = minimal_env();
580        let did = DeploymentId::new();
581        add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
582        env.revisions
583            .push(revision_for(did, RevisionLifecycle::Ready));
584        env.traffic_splits.push(split_for(did));
585        let err = remove_bundle(&mut env, did).unwrap_err();
586        assert_eq!(
587            err.to_string(),
588            format!(
589                "deployment `{did}` is still live: 1 traffic split(s), 1 non-archived \
590                 revision(s). Archive revisions and clear the split first."
591            )
592        );
593        assert_eq!(env.bundles.len(), 1, "env untouched on Err");
594        assert_eq!(env.revisions.len(), 1, "no pruning on refusal");
595    }
596
597    #[test]
598    fn remove_bundle_rejects_unknown_deployment() {
599        let mut env = minimal_env();
600        let did = DeploymentId::new();
601        let err = remove_bundle(&mut env, did).unwrap_err();
602        assert!(matches!(err, BundleError::DeploymentNotFound { .. }));
603    }
604
605    // --- wire-format pins (the encoding the PR-3b client established) ---
606
607    #[test]
608    fn add_payload_wire_encoding_is_pinned() {
609        let payload = add_payload("acme", "cust-1");
610        let value = serde_json::to_value(&payload).unwrap();
611        assert_eq!(
612            value,
613            serde_json::json!({
614                "bundle_id": "acme",
615                "customer_id": "cust-1",
616                "revenue_share": [{"party_id": "greentic", "basis_points": 10_000}],
617                "config_overrides": {},
618            })
619        );
620        let decoded: AddBundlePayload = serde_json::from_value(value).unwrap();
621        assert_eq!(decoded, payload);
622    }
623
624    #[test]
625    fn add_payload_decodes_without_optional_fields() {
626        let decoded: AddBundlePayload = serde_json::from_value(serde_json::json!({
627            "bundle_id": "acme",
628            "customer_id": "cust-1",
629            "revenue_share": [],
630        }))
631        .unwrap();
632        assert!(decoded.route_binding.is_none());
633        assert!(decoded.authorization_ref.is_none());
634        assert!(decoded.config_overrides.is_empty());
635    }
636
637    #[test]
638    fn update_payload_wire_encoding_is_pinned() {
639        let did = DeploymentId::new();
640        let payload = UpdateBundlePayload {
641            deployment_id: did,
642            status: Some(BundleDeploymentStatus::Paused),
643            route_binding: None,
644            revenue_share: None,
645            config_overrides: None,
646        };
647        let value = serde_json::to_value(&payload).unwrap();
648        assert_eq!(
649            value,
650            serde_json::json!({
651                "deployment_id": did.to_string(),
652                "status": "paused",
653            })
654        );
655        let decoded: UpdateBundlePayload = serde_json::from_value(value).unwrap();
656        assert_eq!(decoded, payload);
657    }
658
659    #[test]
660    fn remove_outcome_wire_encoding_round_trips() {
661        let mut env = minimal_env();
662        let did = DeploymentId::new();
663        add_bundle(&mut env, add_payload("acme", "cust-1"), did, fixed_now()).unwrap();
664        env.revisions
665            .push(revision_for(did, RevisionLifecycle::Archived));
666        let outcome = remove_bundle(&mut env, did).unwrap();
667        let value = serde_json::to_value(&outcome).unwrap();
668        assert_eq!(value["deployment"]["deployment_id"], did.to_string());
669        assert_eq!(
670            value["pruned_revision_ids"][0],
671            outcome.pruned_revision_ids[0].to_string()
672        );
673        let decoded: RemoveBundleOutcome = serde_json::from_value(value).unwrap();
674        assert_eq!(decoded, outcome);
675    }
676}