Skip to main content

greentic_deployer/cli/
env_packs.rs

1//! `gtc op env-packs {add,update,remove,rollback,list}` (`A3`).
2//!
3//! Manages `Environment.packs: Vec<EnvPackBinding>` — the env's bound
4//! capability slots. The actual *resolution* of a `PackDescriptor` to a
5//! native handler (deployer, secrets, telemetry, sessions, state) lives in
6//! the env-pack registry (A9) and the wizard QASpec rendering lands in A10;
7//! A3 builds only the binding-storage surface so the operator can declare
8//! intent today.
9//!
10//! Mutations bump `EnvPackBinding.generation` and stash the prior binding
11//! (full JSON) under `Environment` so `rollback` can restore it without a
12//! database. Multi-step rollback (history > 1) is left to A8.
13
14use std::path::PathBuf;
15
16use greentic_deploy_spec::{CapabilitySlot, EnvId, EnvPackBinding, PackDescriptor, PackId};
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19
20use crate::environment::{EnvironmentReads, LocalFsStore};
21
22use super::{
23    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
24    resolve_idempotency_key,
25};
26
27const NOUN: &str = "env-packs";
28
29/// Payload for `op env-packs add` / `op env-packs update`.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct EnvPackBindingPayload {
32    pub environment_id: String,
33    pub slot: CapabilitySlot,
34    pub kind: String,
35    pub pack_ref: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub answers_ref: Option<PathBuf>,
38    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
39    /// surface; when absent, the verb mints one per invocation. Operators
40    /// wanting safe lost-response retries (HTTP backend, PR-3b) supply a
41    /// stable key.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub idempotency_key: Option<String>,
44}
45
46/// Returned by every mutating call.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct BindingSummary {
49    pub environment_id: String,
50    pub slot: CapabilitySlot,
51    pub kind: String,
52    pub pack_ref: String,
53    pub generation: u64,
54    pub has_previous: bool,
55}
56
57impl BindingSummary {
58    pub(crate) fn from_binding(env_id: &EnvId, b: &EnvPackBinding) -> Self {
59        Self {
60            environment_id: env_id.as_str().to_string(),
61            slot: b.slot,
62            kind: b.kind.to_string(),
63            pack_ref: b.pack_ref.as_str().to_string(),
64            generation: b.generation,
65            has_previous: b.previous_binding_ref.is_some(),
66        }
67    }
68}
69
70pub fn add(
71    store: &LocalFsStore,
72    flags: &OpFlags,
73    payload: Option<EnvPackBindingPayload>,
74) -> Result<OpOutcome, OpError> {
75    if flags.schema_only {
76        return Ok(OpOutcome::new(NOUN, "add", payload_schema()));
77    }
78    let payload = resolve_payload(flags, payload)?;
79    let env_id = parse_env_id(&payload.environment_id)?;
80    let binding = build_binding(&payload, 0, None)?;
81    let target = json!({"slot": payload.slot, "kind": payload.kind});
82    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
83    let ctx = AuditCtx {
84        env_id: env_id.clone(),
85        noun: NOUN,
86        verb: "add",
87        target,
88        idempotency_key: Some(idempotency_key.as_str().to_string()),
89    };
90    audit_and_record(store, ctx, |_committed| {
91        let added = store
92            .add_pack_binding(&env_id, binding, idempotency_key)
93            .map_err(map_store_err_preserving_noun)?;
94        let summary = BindingSummary::from_binding(&env_id, &added);
95        let outcome = OpOutcome::new(
96            NOUN,
97            "add",
98            serde_json::to_value(summary).expect("BindingSummary is json-safe"),
99        );
100        Ok((
101            outcome,
102            super::AuditGens {
103                previous: None,
104                new: Some(0),
105            },
106        ))
107    })
108}
109
110pub fn update(
111    store: &LocalFsStore,
112    flags: &OpFlags,
113    payload: Option<EnvPackBindingPayload>,
114) -> Result<OpOutcome, OpError> {
115    if flags.schema_only {
116        return Ok(OpOutcome::new(NOUN, "update", payload_schema()));
117    }
118    let payload = resolve_payload(flags, payload)?;
119    let env_id = parse_env_id(&payload.environment_id)?;
120    let slot = payload.slot;
121    let binding = build_binding(&payload, 0, None)?;
122    let target = json!({"slot": payload.slot, "kind": payload.kind});
123    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
124    let ctx = AuditCtx {
125        env_id: env_id.clone(),
126        noun: NOUN,
127        verb: "update",
128        target,
129        idempotency_key: Some(idempotency_key.as_str().to_string()),
130    };
131    audit_and_record(store, ctx, |_committed| {
132        let (new_binding, new_generation) = store
133            .update_pack_binding(&env_id, slot, binding, idempotency_key)
134            .map_err(map_store_err_preserving_noun)?;
135        let summary = BindingSummary::from_binding(&env_id, &new_binding);
136        let gens = super::AuditGens {
137            previous: Some(new_generation.saturating_sub(1)),
138            new: Some(new_generation),
139        };
140        let outcome = OpOutcome::new(
141            NOUN,
142            "update",
143            serde_json::to_value(summary).expect("BindingSummary is json-safe"),
144        );
145        Ok((outcome, gens))
146    })
147}
148
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct EnvPackRemovePayload {
151    pub environment_id: String,
152    pub slot: CapabilitySlot,
153    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI
154    /// surface; when absent, the verb mints one per invocation.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub idempotency_key: Option<String>,
157}
158
159pub fn remove(
160    store: &LocalFsStore,
161    flags: &OpFlags,
162    payload: Option<EnvPackRemovePayload>,
163) -> Result<OpOutcome, OpError> {
164    if flags.schema_only {
165        return Ok(OpOutcome::new(NOUN, "remove", remove_schema()));
166    }
167    let payload = resolve_payload::<EnvPackRemovePayload>(flags, payload)?;
168    let env_id = parse_env_id(&payload.environment_id)?;
169    let slot = payload.slot;
170    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
171    let ctx = AuditCtx {
172        env_id: env_id.clone(),
173        noun: NOUN,
174        verb: "remove",
175        target: json!({"slot": slot}),
176        idempotency_key: Some(idempotency_key.as_str().to_string()),
177    };
178    audit_and_record(store, ctx, |_committed| {
179        let (removed, removed_generation) = store
180            .remove_pack_binding(&env_id, slot, idempotency_key)
181            .map_err(map_store_err_preserving_noun)?;
182        let summary = BindingSummary::from_binding(&env_id, &removed);
183        let gens = super::AuditGens {
184            previous: Some(removed_generation),
185            new: None,
186        };
187        let outcome = OpOutcome::new(
188            NOUN,
189            "remove",
190            serde_json::to_value(summary).expect("BindingSummary is json-safe"),
191        );
192        Ok((outcome, gens))
193    })
194}
195
196pub fn rollback(
197    store: &LocalFsStore,
198    flags: &OpFlags,
199    payload: Option<EnvPackRemovePayload>,
200) -> Result<OpOutcome, OpError> {
201    if flags.schema_only {
202        return Ok(OpOutcome::new(NOUN, "rollback", remove_schema()));
203    }
204    let payload = resolve_payload::<EnvPackRemovePayload>(flags, payload)?;
205    let env_id = parse_env_id(&payload.environment_id)?;
206    let slot = payload.slot;
207    let idempotency_key = resolve_idempotency_key(payload.idempotency_key)?;
208    let ctx = AuditCtx {
209        env_id: env_id.clone(),
210        noun: NOUN,
211        verb: "rollback",
212        target: json!({"slot": slot}),
213        idempotency_key: Some(idempotency_key.as_str().to_string()),
214    };
215    audit_and_record(store, ctx, |_committed| {
216        let (restored, new_generation) = store
217            .rollback_pack_binding(&env_id, slot, idempotency_key)
218            .map_err(map_store_err_preserving_noun)?;
219        let summary = BindingSummary::from_binding(&env_id, &restored);
220        let gens = super::AuditGens {
221            previous: Some(new_generation.saturating_sub(1)),
222            new: Some(new_generation),
223        };
224        let outcome = OpOutcome::new(
225            NOUN,
226            "rollback",
227            serde_json::to_value(summary).expect("BindingSummary is json-safe"),
228        );
229        Ok((outcome, gens))
230    })
231}
232
233pub fn list(
234    store: &dyn EnvironmentReads,
235    flags: &OpFlags,
236    env_id: &str,
237) -> Result<OpOutcome, OpError> {
238    if flags.schema_only {
239        return Ok(OpOutcome::new(
240            NOUN,
241            "list",
242            json!({"input_schema": "env_id positional"}),
243        ));
244    }
245    let env_id = parse_env_id(env_id)?;
246    if !store.env_exists(&env_id)? {
247        return Err(OpError::NotFound(format!("environment `{env_id}`")));
248    }
249    let env = store.load_env(&env_id)?;
250    let bindings: Vec<BindingSummary> = env
251        .packs
252        .iter()
253        .map(|b| BindingSummary::from_binding(&env_id, b))
254        .collect();
255    Ok(OpOutcome::new(
256        NOUN,
257        "list",
258        json!({"environment_id": env_id.as_str(), "bindings": bindings}),
259    ))
260}
261
262// --- internals -----------------------------------------------------------
263
264fn resolve_payload<T: serde::de::DeserializeOwned>(
265    flags: &OpFlags,
266    payload: Option<T>,
267) -> Result<T, OpError> {
268    if let Some(p) = payload {
269        return Ok(p);
270    }
271    if let Some(path) = &flags.answers {
272        return super::load_answers::<T>(path);
273    }
274    Err(OpError::InvalidArgument(
275        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
276    ))
277}
278
279fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
280    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
281}
282
283pub(crate) fn build_binding(
284    payload: &EnvPackBindingPayload,
285    generation: u64,
286    previous_binding_ref: Option<PathBuf>,
287) -> Result<EnvPackBinding, OpError> {
288    // N-per-env slots (Messaging, Extension) live in their own collections,
289    // never in `packs`. Reject them here with a pointer to the right noun so a
290    // binding can't be wedged into `packs` where its second instance would be
291    // falsely rejected as a duplicate slot.
292    if !payload.slot.binds_in_packs() {
293        let noun = match payload.slot {
294            CapabilitySlot::Messaging => "op messaging endpoint",
295            CapabilitySlot::Extension => "op extensions",
296            _ => unreachable!("binds_in_packs() is false only for Messaging/Extension"),
297        };
298        return Err(OpError::InvalidArgument(format!(
299            "slot `{}` is N-per-env and is not bound via `op env-packs`; use `{noun}` instead",
300            payload.slot
301        )));
302    }
303    let kind = PackDescriptor::try_new(payload.kind.clone())
304        .map_err(|e| OpError::InvalidArgument(format!("kind: {e}")))?;
305    Ok(EnvPackBinding {
306        slot: payload.slot,
307        kind,
308        pack_ref: PackId::new(payload.pack_ref.clone()),
309        answers_ref: payload.answers_ref.clone(),
310        generation,
311        previous_binding_ref,
312    })
313}
314
315fn payload_schema() -> Value {
316    json!({
317        "$schema": "https://json-schema.org/draft/2020-12/schema",
318        "title": "EnvPackBindingPayload",
319        "type": "object",
320        "required": ["environment_id", "slot", "kind", "pack_ref"],
321        "additionalProperties": false,
322        "properties": {
323            "environment_id": {"type": "string"},
324            "slot": {"type": "string", "enum": ["deployer", "secrets", "telemetry", "sessions", "state", "revocation"]},
325            "kind": {"type": "string", "description": "PackDescriptor — `<namespace>.<id>@<semver>`."},
326            "pack_ref": {"type": "string"},
327            "answers_ref": {"type": ["string", "null"]},
328            "idempotency_key": {
329                "type": "string",
330                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
331            }
332        }
333    })
334}
335
336fn remove_schema() -> Value {
337    json!({
338        "$schema": "https://json-schema.org/draft/2020-12/schema",
339        "title": "EnvPackRemovePayload",
340        "type": "object",
341        "required": ["environment_id", "slot"],
342        "additionalProperties": false,
343        "properties": {
344            "environment_id": {"type": "string"},
345            "slot": {"type": "string", "enum": ["deployer", "secrets", "telemetry", "sessions", "state", "revocation"]},
346            "idempotency_key": {
347                "type": "string",
348                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
349            }
350        }
351    })
352}
353
354// The previous-binding stash (one-step rollback, `inline://` base64
355// tokens) lives in `greentic_deploy_spec::engine::inline_stash`; the
356// binding verbs that write/read it moved to
357// `greentic_deploy_spec::engine::bindings` in PR-4.2d, so this module no
358// longer touches the encoding directly.
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363    use crate::cli::tests_common::make_env;
364    use crate::environment::EnvironmentStore;
365    use crate::environment::LocalFsStore;
366    use tempfile::tempdir;
367
368    fn local_payload(slot: CapabilitySlot, kind: &str) -> EnvPackBindingPayload {
369        EnvPackBindingPayload {
370            environment_id: "local".to_string(),
371            slot,
372            kind: kind.to_string(),
373            pack_ref: kind.split('@').next().unwrap_or(kind).to_string(),
374            answers_ref: None,
375            idempotency_key: None,
376        }
377    }
378
379    #[test]
380    fn add_then_list_returns_binding() {
381        let dir = tempdir().unwrap();
382        let store = LocalFsStore::new(dir.path());
383        store.save(&make_env("local")).unwrap();
384        let outcome = add(
385            &store,
386            &OpFlags::default(),
387            Some(local_payload(
388                CapabilitySlot::Secrets,
389                "greentic.secrets.dev-store@1.0.0",
390            )),
391        )
392        .unwrap();
393        assert_eq!(outcome.op, "add");
394        let list_outcome = list(&store, &OpFlags::default(), "local").unwrap();
395        let bindings = list_outcome
396            .result
397            .get("bindings")
398            .and_then(|v| v.as_array())
399            .expect("bindings array");
400        assert_eq!(bindings.len(), 1);
401        assert_eq!(
402            bindings[0].get("slot").and_then(|v| v.as_str()),
403            Some("secrets")
404        );
405    }
406
407    #[test]
408    fn add_rejects_duplicate_slot() {
409        let dir = tempdir().unwrap();
410        let store = LocalFsStore::new(dir.path());
411        store.save(&make_env("local")).unwrap();
412        let p = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
413        add(&store, &OpFlags::default(), Some(p.clone())).unwrap();
414        let err = add(&store, &OpFlags::default(), Some(p)).unwrap_err();
415        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
416    }
417
418    #[test]
419    fn update_then_rollback_restores_previous() {
420        let dir = tempdir().unwrap();
421        let store = LocalFsStore::new(dir.path());
422        store.save(&make_env("local")).unwrap();
423        let p1 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
424        add(&store, &OpFlags::default(), Some(p1.clone())).unwrap();
425
426        let p2 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.aws-sm@1.0.0");
427        let updated = update(&store, &OpFlags::default(), Some(p2.clone())).unwrap();
428        assert_eq!(
429            updated.result.get("kind").and_then(|v| v.as_str()),
430            Some("greentic.secrets.aws-sm@1.0.0")
431        );
432        assert_eq!(
433            updated.result.get("generation").and_then(|v| v.as_u64()),
434            Some(1)
435        );
436        assert_eq!(
437            updated.result.get("has_previous").and_then(|v| v.as_bool()),
438            Some(true)
439        );
440
441        let rolled = rollback(
442            &store,
443            &OpFlags::default(),
444            Some(EnvPackRemovePayload {
445                environment_id: "local".to_string(),
446                slot: CapabilitySlot::Secrets,
447                idempotency_key: None,
448            }),
449        )
450        .unwrap();
451        assert_eq!(
452            rolled.result.get("kind").and_then(|v| v.as_str()),
453            Some("greentic.secrets.dev-store@1.0.0")
454        );
455        assert_eq!(
456            rolled.result.get("generation").and_then(|v| v.as_u64()),
457            Some(2),
458            "rollback bumps the generation past the update"
459        );
460    }
461
462    #[test]
463    fn remove_then_list_empty() {
464        let dir = tempdir().unwrap();
465        let store = LocalFsStore::new(dir.path());
466        store.save(&make_env("local")).unwrap();
467        let p = local_payload(CapabilitySlot::Telemetry, "greentic.telemetry.stdout@1.0.0");
468        add(&store, &OpFlags::default(), Some(p)).unwrap();
469        remove(
470            &store,
471            &OpFlags::default(),
472            Some(EnvPackRemovePayload {
473                environment_id: "local".to_string(),
474                slot: CapabilitySlot::Telemetry,
475                idempotency_key: None,
476            }),
477        )
478        .unwrap();
479        let list_outcome = list(&store, &OpFlags::default(), "local").unwrap();
480        let bindings = list_outcome
481            .result
482            .get("bindings")
483            .and_then(|v| v.as_array())
484            .expect("bindings array");
485        assert!(bindings.is_empty());
486    }
487
488    #[test]
489    fn rollback_without_history_errors() {
490        let dir = tempdir().unwrap();
491        let store = LocalFsStore::new(dir.path());
492        store.save(&make_env("local")).unwrap();
493        let p = local_payload(CapabilitySlot::State, "greentic.state.in-memory@1.0.0");
494        add(&store, &OpFlags::default(), Some(p)).unwrap();
495        let err = rollback(
496            &store,
497            &OpFlags::default(),
498            Some(EnvPackRemovePayload {
499                environment_id: "local".to_string(),
500                slot: CapabilitySlot::State,
501                idempotency_key: None,
502            }),
503        )
504        .unwrap_err();
505        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
506    }
507
508    #[test]
509    fn add_rejects_n_per_env_slots() {
510        let dir = tempdir().unwrap();
511        let store = LocalFsStore::new(dir.path());
512        store.save(&make_env("local")).unwrap();
513        // Extension and Messaging are N-per-env: they live in their own
514        // collections, not `packs`. `op env-packs` must refuse them with a
515        // pointer to the right noun rather than wedge them into `packs`.
516        for slot in [CapabilitySlot::Extension, CapabilitySlot::Messaging] {
517            let err = add(
518                &store,
519                &OpFlags::default(),
520                Some(local_payload(slot, "acme.oauth.auth0@1.0.0")),
521            )
522            .unwrap_err();
523            assert!(
524                matches!(err, OpError::InvalidArgument(_)),
525                "slot {slot} should be rejected, got {err:?}"
526            );
527        }
528    }
529
530    #[test]
531    fn concurrent_pack_adds_both_land() {
532        // Codex regression: previously every mutator did bare
533        // `store.load() → mutate → store.save()`, with the per-env flock
534        // only held during save. Two parallel `add`s could both read the
535        // same preimage and the later save would clobber the earlier
536        // mutation. With every mutator now wrapped in `transact()`, both
537        // bindings must survive.
538        use std::sync::Arc;
539        use std::thread;
540        let dir = tempdir().unwrap();
541        let store = Arc::new(LocalFsStore::new(dir.path()));
542        store.save(&make_env("local")).unwrap();
543        let store_a = Arc::clone(&store);
544        let store_b = Arc::clone(&store);
545        // Two slots, two threads. Without transact, one of these stomps
546        // the other roughly half the time on a busy system.
547        let h_a = thread::spawn(move || {
548            add(
549                &store_a,
550                &OpFlags::default(),
551                Some(local_payload(
552                    CapabilitySlot::Secrets,
553                    "greentic.secrets.dev-store@1.0.0",
554                )),
555            )
556        });
557        let h_b = thread::spawn(move || {
558            add(
559                &store_b,
560                &OpFlags::default(),
561                Some(local_payload(
562                    CapabilitySlot::Telemetry,
563                    "greentic.telemetry.stdout@1.0.0",
564                )),
565            )
566        });
567        h_a.join().unwrap().unwrap();
568        h_b.join().unwrap().unwrap();
569        let listed = list(store.as_ref(), &OpFlags::default(), "local").unwrap();
570        let bindings = listed
571            .result
572            .get("bindings")
573            .and_then(|v| v.as_array())
574            .expect("bindings array");
575        assert_eq!(
576            bindings.len(),
577            2,
578            "both slot bindings must survive concurrent transact()s"
579        );
580    }
581
582    #[test]
583    fn update_records_previous_and_new_generation_in_audit() {
584        let dir = tempdir().unwrap();
585        let store = LocalFsStore::new(dir.path());
586        store.save(&make_env("local")).unwrap();
587        let p1 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
588        add(&store, &OpFlags::default(), Some(p1)).unwrap();
589        let p2 = local_payload(CapabilitySlot::Secrets, "greentic.secrets.aws-sm@1.0.0");
590        update(&store, &OpFlags::default(), Some(p2)).unwrap();
591        let log = dir.path().join("local").join("audit").join("events.jsonl");
592        let raw = std::fs::read_to_string(&log).unwrap();
593        let lines: Vec<&str> = raw.lines().collect();
594        assert_eq!(lines.len(), 2, "add + update each emit one audit event");
595        let update_event: crate::environment::AuditEvent = serde_json::from_str(lines[1]).unwrap();
596        assert_eq!(update_event.verb, "update");
597        assert_eq!(update_event.previous_generation, Some(0));
598        assert_eq!(update_event.new_generation, Some(1));
599    }
600
601    // --- PR-3a.8: schema regression tests for idempotency_key ---------------
602
603    /// `EnvPackBindingPayload` accepts `idempotency_key`; the schema
604    /// published via `--schema` MUST list it so schema-driven callers can
605    /// supply the A8 retry key.
606    #[test]
607    fn add_schema_lists_idempotency_key() {
608        let schema = payload_schema();
609        assert!(
610            schema.pointer("/properties/idempotency_key").is_some(),
611            "payload_schema must list `idempotency_key` (schema: {schema:#})"
612        );
613    }
614
615    /// Same gate for the remove/rollback schema.
616    #[test]
617    fn remove_schema_lists_idempotency_key() {
618        let schema = remove_schema();
619        assert!(
620            schema.pointer("/properties/idempotency_key").is_some(),
621            "remove_schema must list `idempotency_key` (schema: {schema:#})"
622        );
623    }
624
625    /// Audit events emitted by the typed-verb path carry the resolved
626    /// idempotency key (either caller-supplied or freshly minted).
627    #[test]
628    fn add_audit_event_carries_idempotency_key() {
629        let dir = tempdir().unwrap();
630        let store = LocalFsStore::new(dir.path());
631        store.save(&make_env("local")).unwrap();
632        let p = local_payload(CapabilitySlot::Secrets, "greentic.secrets.dev-store@1.0.0");
633        add(&store, &OpFlags::default(), Some(p)).unwrap();
634        let log = dir.path().join("local").join("audit").join("events.jsonl");
635        let raw = std::fs::read_to_string(&log).unwrap();
636        let event: crate::environment::AuditEvent =
637            serde_json::from_str(raw.lines().next().unwrap()).unwrap();
638        assert!(
639            event.idempotency_key.is_some(),
640            "add audit event must carry an idempotency_key"
641        );
642    }
643
644    /// Typed `remove_pack_binding` via the CLI correctly maps
645    /// `StoreError::DependentNotFound` to `OpError::NotFound`.
646    #[test]
647    fn remove_missing_slot_returns_not_found() {
648        let dir = tempdir().unwrap();
649        let store = LocalFsStore::new(dir.path());
650        store.save(&make_env("local")).unwrap();
651        let err = remove(
652            &store,
653            &OpFlags::default(),
654            Some(EnvPackRemovePayload {
655                environment_id: "local".to_string(),
656                slot: CapabilitySlot::Secrets,
657                idempotency_key: None,
658            }),
659        )
660        .unwrap_err();
661        assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
662    }
663
664    /// Typed `rollback_pack_binding` via the CLI correctly maps
665    /// `StoreError::DependentNotFound` to `OpError::NotFound`.
666    #[test]
667    fn rollback_missing_slot_returns_not_found() {
668        let dir = tempdir().unwrap();
669        let store = LocalFsStore::new(dir.path());
670        store.save(&make_env("local")).unwrap();
671        let err = rollback(
672            &store,
673            &OpFlags::default(),
674            Some(EnvPackRemovePayload {
675                environment_id: "local".to_string(),
676                slot: CapabilitySlot::Secrets,
677                idempotency_key: None,
678            }),
679        )
680        .unwrap_err();
681        assert!(matches!(err, OpError::NotFound(_)), "got {err:?}");
682    }
683}