Skip to main content

greentic_deploy_spec/engine/
bindings.rs

1//! Pure pack/extension-binding verb semantics (Phase D PR-4.2d).
2//!
3//! The bindings verb group (`op env-packs add | update | remove | rollback`
4//! and `op extensions add | update | remove | rollback`) follows the
5//! PR-4.2a engine contract: pure `&mut Environment` transforms with no
6//! I/O and no key material. Both `LocalFsStore` (greentic-deployer,
7//! behind a flock) and the operator-store-server (behind SQLite CAS)
8//! drive the SAME functions, so the one-step-rollback stash contract and
9//! the 1-per-slot rule cannot drift between local and remote.
10//!
11//! # Persist rule (read before calling)
12//!
13//! The simplest of the verb groups: every check runs BEFORE the single
14//! mutation at the end of each transform, and there is no idempotent
15//! no-op branch —
16//!
17//! - any `Ok(_)` — the env was mutated; persist it.
18//! - any `Err(_)` — the env was not mutated; nothing to persist.
19//!
20//! The A8 `Idempotency-Key` is transport metadata only here (unlike the
21//! traffic group, where it is domain state): the engine never sees it,
22//! the server echoes it into the audit record, and replay caching is the
23//! PR-4.3 ledger.
24//!
25//! # Stash bounding
26//!
27//! `update_*_binding` snapshots the prior binding into the new binding's
28//! `previous_binding_ref` via [`inline_stash`] so one-step `rollback`
29//! works without a sidecar history file. The snapshot is taken from a
30//! clone with `previous_binding_ref` cleared — stashing the field as-is
31//! would nest every prior stash inside the next (geometric growth across
32//! routine updates) for history that one-step rollback can never reach
33//! (`rollback_*_binding` clears the restored ref). Same fix and
34//! regression-test shape as the traffic group's split stash.
35//!
36//! # Wire shapes
37//!
38//! [`PackBindingPayload`] / [`ExtensionBindingPayload`] /
39//! [`ExtensionKeyedPayload`] double as the A8 request bodies (the
40//! `/environments/{env}/packs[/{slot}[/rollback]]` and
41//! `/environments/{env}/extensions[/rollback]` routes);
42//! [`BindingGenerationOutcome`] is the response body for the verbs that
43//! return `(binding, generation)` (`add` returns the bare binding). The
44//! wire-format tests at the bottom pin the encoding the PR-3b client
45//! established.
46//!
47//! # One place stricter than the moved local code
48//!
49//! [`add_pack_binding`] / [`update_pack_binding`] reject N-per-env slots
50//! (`messaging` / `extension`) via [`CapabilitySlot::binds_in_packs`].
51//! The deployer CLI already rejects these upstream (`op env-packs`
52//! points at the right noun), so the check was unreachable through the
53//! local path — but the store-server takes the binding straight off the
54//! wire, and without the guard a remote client could wedge an N-per-env
55//! binding into `packs`, where its second instance would be falsely
56//! rejected as a duplicate slot. [`update_extension_binding`] similarly
57//! rejects a replacement binding whose `(kind_path, instance_id)` differs
58//! from the target key (`ExtensionKeyMismatch`, the pack family's
59//! `SlotMismatch` mirror); CLI callers derive both from one payload so the
60//! check is wire-surface-only.
61
62use serde::{Deserialize, Serialize};
63use std::path::PathBuf;
64use thiserror::Error;
65
66use crate::capability_slot::CapabilitySlot;
67use crate::engine::ExtensionKey;
68use crate::engine::inline_stash;
69use crate::environment::{EnvPackBinding, Environment, ExtensionBinding};
70use greentic_types::EnvId;
71
72// ---------------------------------------------------------------------------
73// Errors
74// ---------------------------------------------------------------------------
75
76/// Why a binding verb refused to mutate the environment. Display strings
77/// are verbatim what `LocalFsStore` raised before the move (PR-4.2d), so
78/// operator-facing CLI errors are unchanged.
79#[derive(Debug, Clone, PartialEq, Eq, Error)]
80pub enum BindingError {
81    // --- pack family ---
82    #[error("slot `{slot}` already bound on env `{env_id}`; use update")]
83    SlotAlreadyBound { slot: CapabilitySlot, env_id: EnvId },
84    #[error("slot `{slot}` not bound on env `{env_id}`")]
85    SlotNotBound { slot: CapabilitySlot, env_id: EnvId },
86    #[error("binding slot `{binding_slot}` does not match target slot `{target_slot}`")]
87    SlotMismatch {
88        binding_slot: CapabilitySlot,
89        target_slot: CapabilitySlot,
90    },
91    /// N-per-env slots (`messaging` / `extension`) never bind in `packs`
92    /// — see the module doc's "stricter than the moved local code" note.
93    #[error("slot `{slot}` is N-per-env and does not bind in `packs`")]
94    NotPackSlot { slot: CapabilitySlot },
95    #[error("slot `{slot}` on env `{env_id}` has no previous binding to roll back to")]
96    SlotNoPrevious { slot: CapabilitySlot, env_id: EnvId },
97    #[error("previous binding payload `{}` missing for slot `{slot}`", prev_ref.display())]
98    SlotSnapshotMissing {
99        prev_ref: PathBuf,
100        slot: CapabilitySlot,
101    },
102    #[error("slot `{slot}` on env `{env_id}`: generation overflow ({generation})")]
103    SlotGenerationOverflow {
104        slot: CapabilitySlot,
105        env_id: EnvId,
106        generation: u64,
107    },
108
109    // --- extension family ---
110    #[error("extension `{key}` is already bound on env `{env_id}`; use update")]
111    ExtensionAlreadyBound { key: ExtensionKey, env_id: EnvId },
112    #[error("extension `{key}` not bound on env `{env_id}`")]
113    ExtensionNotBound { key: ExtensionKey, env_id: EnvId },
114    #[error("binding key `{binding_key}` does not match target key `{target_key}`")]
115    ExtensionKeyMismatch {
116        binding_key: ExtensionKey,
117        target_key: ExtensionKey,
118    },
119    #[error("extension `{key}` on env `{env_id}` has no previous binding to roll back to")]
120    ExtensionNoPrevious { key: ExtensionKey, env_id: EnvId },
121    #[error("previous binding payload `{}` missing for extension `{key}`", prev_ref.display())]
122    ExtensionSnapshotMissing {
123        prev_ref: PathBuf,
124        key: ExtensionKey,
125    },
126    #[error("extension `{key}` on env `{env_id}`: generation overflow ({generation})")]
127    ExtensionGenerationOverflow {
128        key: ExtensionKey,
129        env_id: EnvId,
130        generation: u64,
131    },
132
133    // --- both families ---
134    #[error("snapshot prior binding: {detail}")]
135    SnapshotEncode { detail: String },
136    #[error("deserialise previous binding: {detail}")]
137    SnapshotDecode { detail: String },
138}
139
140// ---------------------------------------------------------------------------
141// Wire payloads / outcomes (shared client ⇄ server shapes)
142// ---------------------------------------------------------------------------
143
144/// Request body for `add_pack_binding` (POST `…/packs`) and
145/// `update_pack_binding` (PATCH `…/packs/{slot}` — the target slot rides
146/// the URL). Encoding pinned by the PR-3b client.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PackBindingPayload {
149    pub binding: EnvPackBinding,
150}
151
152/// Request body for `add_extension_binding` (POST `…/extensions`).
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ExtensionBindingPayload {
155    pub binding: ExtensionBinding,
156}
157
158/// Request body for the keyed extension verbs: `update` (PATCH,
159/// `binding: Some(_)` required), `remove` (DELETE) and `rollback` (POST)
160/// send the key only. One struct, three routes — the verbs diverge by
161/// method/URL, exactly as the PR-3b client serializes them.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ExtensionKeyedPayload {
164    pub key: ExtensionKey,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub binding: Option<ExtensionBinding>,
167}
168
169/// Response body for the binding verbs that return `(binding,
170/// new_generation)` — pack/extension `update`, `remove` and `rollback`
171/// (`add` returns the bare binding). The generation is surfaced
172/// explicitly for downstream observability even though the binding
173/// carries it, pinning the PR-3b client's decode shape.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BindingGenerationOutcome<T> {
176    pub binding: T,
177    pub generation: u64,
178}
179
180// ---------------------------------------------------------------------------
181// Pure transforms — pack family
182// ---------------------------------------------------------------------------
183
184/// Bind a new env-pack slot. Rejects [`BindingError::SlotAlreadyBound`]
185/// when the slot is already bound (callers should `update` instead) and
186/// [`BindingError::NotPackSlot`] for N-per-env slots.
187pub fn add_pack_binding(
188    env: &mut Environment,
189    binding: EnvPackBinding,
190) -> Result<EnvPackBinding, BindingError> {
191    if !binding.slot.binds_in_packs() {
192        return Err(BindingError::NotPackSlot { slot: binding.slot });
193    }
194    if env.pack_for_slot(binding.slot).is_some() {
195        return Err(BindingError::SlotAlreadyBound {
196            slot: binding.slot,
197            env_id: env.environment_id.clone(),
198        });
199    }
200    env.packs.push(binding.clone());
201    Ok(binding)
202}
203
204/// Replace the binding on an existing slot. Bumps `generation` to
205/// `previous + 1` and stashes the prior binding inline (with its own
206/// `previous_binding_ref` cleared — see the module doc's stash-bounding
207/// note) so one-step [`rollback_pack_binding`] works.
208///
209/// Returns `(new_binding, new_generation)`.
210pub fn update_pack_binding(
211    env: &mut Environment,
212    slot: CapabilitySlot,
213    binding: EnvPackBinding,
214) -> Result<(EnvPackBinding, u64), BindingError> {
215    if !slot.binds_in_packs() {
216        return Err(BindingError::NotPackSlot { slot });
217    }
218    let idx = find_pack_slot(env, slot)?;
219    if binding.slot != slot {
220        return Err(BindingError::SlotMismatch {
221            binding_slot: binding.slot,
222            target_slot: slot,
223        });
224    }
225    let prev_generation = env.packs[idx].generation;
226    let new_generation =
227        prev_generation
228            .checked_add(1)
229            .ok_or_else(|| BindingError::SlotGenerationOverflow {
230                slot,
231                env_id: env.environment_id.clone(),
232                generation: prev_generation,
233            })?;
234    // Snapshot a clone with the nested ref cleared — one-step rollback
235    // can never reach deeper history, and stashing it would nest every
236    // prior stash inside the next.
237    let mut snapshot_source = env.packs[idx].clone();
238    snapshot_source.previous_binding_ref = None;
239    let prev_snapshot =
240        serde_json::to_value(&snapshot_source).map_err(|e| BindingError::SnapshotEncode {
241            detail: e.to_string(),
242        })?;
243    let mut new_binding = EnvPackBinding {
244        generation: new_generation,
245        ..binding
246    };
247    new_binding.previous_binding_ref = Some(inline_stash::stash_inline(prev_snapshot));
248    env.packs[idx] = new_binding;
249    Ok((env.packs[idx].clone(), new_generation))
250}
251
252/// Remove a pack-binding slot. Returns `(removed_binding,
253/// removed_generation)`.
254pub fn remove_pack_binding(
255    env: &mut Environment,
256    slot: CapabilitySlot,
257) -> Result<(EnvPackBinding, u64), BindingError> {
258    let idx = find_pack_slot(env, slot)?;
259    let removed = env.packs.remove(idx);
260    let generation = removed.generation;
261    Ok((removed, generation))
262}
263
264/// Roll a pack-binding slot back to its one-step-previous snapshot.
265/// Bumps generation past the current one and clears the restored ref so
266/// a second rollback fails (single-step only).
267///
268/// Returns `(restored_binding, new_generation)`.
269pub fn rollback_pack_binding(
270    env: &mut Environment,
271    slot: CapabilitySlot,
272) -> Result<(EnvPackBinding, u64), BindingError> {
273    let idx = find_pack_slot(env, slot)?;
274    let prev_generation = env.packs[idx].generation;
275    let new_generation =
276        prev_generation
277            .checked_add(1)
278            .ok_or_else(|| BindingError::SlotGenerationOverflow {
279                slot,
280                env_id: env.environment_id.clone(),
281                generation: prev_generation,
282            })?;
283    let prev_ref = env.packs[idx].previous_binding_ref.clone().ok_or_else(|| {
284        BindingError::SlotNoPrevious {
285            slot,
286            env_id: env.environment_id.clone(),
287        }
288    })?;
289    let prev_value = inline_stash::load_inline(&prev_ref)
290        .ok_or(BindingError::SlotSnapshotMissing { prev_ref, slot })?;
291    let mut restored: EnvPackBinding =
292        serde_json::from_value(prev_value).map_err(|e| BindingError::SnapshotDecode {
293            detail: e.to_string(),
294        })?;
295    restored.generation = new_generation;
296    restored.previous_binding_ref = None;
297    env.packs[idx] = restored;
298    Ok((env.packs[idx].clone(), new_generation))
299}
300
301// ---------------------------------------------------------------------------
302// Pure transforms — extension family
303// ---------------------------------------------------------------------------
304
305/// Add a new extension binding. Rejects
306/// [`BindingError::ExtensionAlreadyBound`] when a binding with the same
307/// `(kind.path(), instance_id)` key exists — a `None` instance and a
308/// `Some(_)` instance on the same path are distinct and coexist.
309pub fn add_extension_binding(
310    env: &mut Environment,
311    binding: ExtensionBinding,
312) -> Result<ExtensionBinding, BindingError> {
313    let key = ExtensionKey::from_binding(&binding);
314    if env.extensions.iter().any(|b| key.matches(b)) {
315        return Err(BindingError::ExtensionAlreadyBound {
316            key,
317            env_id: env.environment_id.clone(),
318        });
319    }
320    env.extensions.push(binding.clone());
321    Ok(binding)
322}
323
324/// Replace an existing extension binding identified by `key`. Bumps
325/// `generation` to `previous + 1` and stashes the prior binding inline
326/// (nested ref cleared — see the module doc) so one-step
327/// [`rollback_extension_binding`] works.
328///
329/// Returns `(new_binding, new_generation)`.
330pub fn update_extension_binding(
331    env: &mut Environment,
332    key: &ExtensionKey,
333    binding: ExtensionBinding,
334) -> Result<(ExtensionBinding, u64), BindingError> {
335    let idx = find_extension(env, key)?;
336    let binding_key = ExtensionKey::from_binding(&binding);
337    if binding_key != *key {
338        return Err(BindingError::ExtensionKeyMismatch {
339            binding_key,
340            target_key: key.clone(),
341        });
342    }
343    let prev_generation = env.extensions[idx].generation;
344    let new_generation = prev_generation.checked_add(1).ok_or_else(|| {
345        BindingError::ExtensionGenerationOverflow {
346            key: key.clone(),
347            env_id: env.environment_id.clone(),
348            generation: prev_generation,
349        }
350    })?;
351    let mut snapshot_source = env.extensions[idx].clone();
352    snapshot_source.previous_binding_ref = None;
353    let prev_snapshot =
354        serde_json::to_value(&snapshot_source).map_err(|e| BindingError::SnapshotEncode {
355            detail: e.to_string(),
356        })?;
357    let mut new_binding = binding;
358    new_binding.generation = new_generation;
359    new_binding.previous_binding_ref = Some(inline_stash::stash_inline(prev_snapshot));
360    env.extensions[idx] = new_binding;
361    Ok((env.extensions[idx].clone(), new_generation))
362}
363
364/// Remove an extension binding identified by `key`. Returns the removed
365/// binding and its generation at the time of removal.
366pub fn remove_extension_binding(
367    env: &mut Environment,
368    key: &ExtensionKey,
369) -> Result<(ExtensionBinding, u64), BindingError> {
370    let idx = find_extension(env, key)?;
371    let removed = env.extensions.remove(idx);
372    let generation = removed.generation;
373    Ok((removed, generation))
374}
375
376/// Roll an extension binding back to its one-step-previous snapshot.
377/// Bumps generation past the current one and clears the restored ref so
378/// a second rollback fails (single-step only).
379///
380/// Returns `(restored_binding, new_generation)`.
381pub fn rollback_extension_binding(
382    env: &mut Environment,
383    key: &ExtensionKey,
384) -> Result<(ExtensionBinding, u64), BindingError> {
385    let idx = find_extension(env, key)?;
386    let prev_generation = env.extensions[idx].generation;
387    let new_generation = prev_generation.checked_add(1).ok_or_else(|| {
388        BindingError::ExtensionGenerationOverflow {
389            key: key.clone(),
390            env_id: env.environment_id.clone(),
391            generation: prev_generation,
392        }
393    })?;
394    let prev_ref = env.extensions[idx]
395        .previous_binding_ref
396        .clone()
397        .ok_or_else(|| BindingError::ExtensionNoPrevious {
398            key: key.clone(),
399            env_id: env.environment_id.clone(),
400        })?;
401    let prev_value = inline_stash::load_inline(&prev_ref).ok_or_else(|| {
402        BindingError::ExtensionSnapshotMissing {
403            prev_ref,
404            key: key.clone(),
405        }
406    })?;
407    let mut restored: ExtensionBinding =
408        serde_json::from_value(prev_value).map_err(|e| BindingError::SnapshotDecode {
409            detail: e.to_string(),
410        })?;
411    restored.generation = new_generation;
412    restored.previous_binding_ref = None;
413    env.extensions[idx] = restored;
414    Ok((env.extensions[idx].clone(), new_generation))
415}
416
417/// Locate the pack binding for `slot`, mirroring [`find_extension`] for
418/// the pack family.
419fn find_pack_slot(env: &Environment, slot: CapabilitySlot) -> Result<usize, BindingError> {
420    env.packs
421        .iter()
422        .position(|b| b.slot == slot)
423        .ok_or_else(|| BindingError::SlotNotBound {
424            slot,
425            env_id: env.environment_id.clone(),
426        })
427}
428
429fn find_extension(env: &Environment, key: &ExtensionKey) -> Result<usize, BindingError> {
430    env.extensions
431        .iter()
432        .position(|b| key.matches(b))
433        .ok_or_else(|| BindingError::ExtensionNotBound {
434            key: key.clone(),
435            env_id: env.environment_id.clone(),
436        })
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::capability_slot::PackDescriptor;
443    use crate::engine::fresh_environment;
444    use crate::environment::EnvironmentHostConfig;
445    use crate::ids::PackId;
446    use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
447
448    fn env_id() -> EnvId {
449        EnvId::try_from("local").unwrap()
450    }
451
452    fn minimal_env() -> Environment {
453        fresh_environment(
454            &env_id(),
455            "Local".to_string(),
456            EnvironmentHostConfig {
457                env_id: env_id(),
458                region: None,
459                tenant_org_id: None,
460                listen_addr: None,
461                public_base_url: None,
462                gui_enabled: None,
463            },
464            RevocationConfig::default(),
465            RetentionPolicy::default(),
466            HealthStatus::default(),
467        )
468    }
469
470    fn pack(slot: CapabilitySlot, kind: &str) -> EnvPackBinding {
471        EnvPackBinding {
472            slot,
473            kind: PackDescriptor::try_new(format!("{kind}@1.0.0")).unwrap(),
474            pack_ref: PackId::new(kind),
475            answers_ref: None,
476            generation: 0,
477            previous_binding_ref: None,
478        }
479    }
480
481    fn ext(kind: &str, instance: Option<&str>) -> ExtensionBinding {
482        ExtensionBinding {
483            kind: PackDescriptor::try_new(format!("{kind}@0.1.0")).unwrap(),
484            pack_ref: PackId::new(kind),
485            instance_id: instance.map(str::to_string),
486            answers_ref: None,
487            generation: 0,
488            previous_binding_ref: None,
489        }
490    }
491
492    // --- pack family ---
493
494    #[test]
495    fn add_pack_binding_appends_and_returns_binding() {
496        let mut env = minimal_env();
497        let added = add_pack_binding(&mut env, pack(CapabilitySlot::Secrets, "greentic.secrets"))
498            .expect("fresh slot binds");
499        assert_eq!(added.slot, CapabilitySlot::Secrets);
500        assert_eq!(env.packs.len(), 1);
501    }
502
503    #[test]
504    fn add_pack_binding_rejects_bound_slot() {
505        let mut env = minimal_env();
506        env.packs
507            .push(pack(CapabilitySlot::Secrets, "greentic.secrets"));
508        let err = add_pack_binding(&mut env, pack(CapabilitySlot::Secrets, "greentic.other"))
509            .unwrap_err();
510        assert!(matches!(err, BindingError::SlotAlreadyBound { .. }));
511        assert_eq!(
512            err.to_string(),
513            "slot `secrets` already bound on env `local`; use update"
514        );
515        assert_eq!(env.packs.len(), 1, "env untouched on Err");
516    }
517
518    #[test]
519    fn add_pack_binding_rejects_n_per_env_slots() {
520        let mut env = minimal_env();
521        for slot in [CapabilitySlot::Messaging, CapabilitySlot::Extension] {
522            let err = add_pack_binding(&mut env, pack(slot, "greentic.thing")).unwrap_err();
523            assert!(matches!(err, BindingError::NotPackSlot { .. }), "{slot}");
524        }
525        assert!(env.packs.is_empty());
526    }
527
528    #[test]
529    fn update_pack_binding_bumps_generation_and_stashes_previous() {
530        let mut env = minimal_env();
531        env.packs
532            .push(pack(CapabilitySlot::Secrets, "greentic.secrets"));
533        let (updated, generation) = update_pack_binding(
534            &mut env,
535            CapabilitySlot::Secrets,
536            pack(CapabilitySlot::Secrets, "greentic.vault"),
537        )
538        .expect("bound slot updates");
539        assert_eq!(generation, 1);
540        assert_eq!(updated.generation, 1);
541        assert_eq!(updated.kind.as_str(), "greentic.vault@1.0.0");
542        let stash = updated.previous_binding_ref.expect("prior binding stashed");
543        let prev = inline_stash::load_inline(&stash).expect("stash decodes");
544        assert_eq!(prev["kind"], "greentic.secrets@1.0.0");
545    }
546
547    #[test]
548    fn update_pack_binding_rejects_unbound_slot_and_slot_mismatch() {
549        let mut env = minimal_env();
550        let err = update_pack_binding(
551            &mut env,
552            CapabilitySlot::Secrets,
553            pack(CapabilitySlot::Secrets, "greentic.vault"),
554        )
555        .unwrap_err();
556        assert_eq!(err.to_string(), "slot `secrets` not bound on env `local`");
557
558        env.packs
559            .push(pack(CapabilitySlot::Secrets, "greentic.secrets"));
560        let err = update_pack_binding(
561            &mut env,
562            CapabilitySlot::Secrets,
563            pack(CapabilitySlot::State, "greentic.state"),
564        )
565        .unwrap_err();
566        assert_eq!(
567            err.to_string(),
568            "binding slot `state` does not match target slot `secrets`"
569        );
570        assert_eq!(env.packs[0].generation, 0, "env untouched on Err");
571    }
572
573    #[test]
574    fn update_pack_binding_rejects_n_per_env_slots() {
575        let mut env = minimal_env();
576        let err = update_pack_binding(
577            &mut env,
578            CapabilitySlot::Messaging,
579            pack(CapabilitySlot::Messaging, "greentic.slack"),
580        )
581        .unwrap_err();
582        assert!(matches!(err, BindingError::NotPackSlot { .. }));
583    }
584
585    #[test]
586    fn remove_pack_binding_returns_removed_binding() {
587        let mut env = minimal_env();
588        env.packs.push(EnvPackBinding {
589            generation: 3,
590            ..pack(CapabilitySlot::Secrets, "greentic.secrets")
591        });
592        let (removed, generation) =
593            remove_pack_binding(&mut env, CapabilitySlot::Secrets).expect("bound slot removes");
594        assert_eq!(generation, 3);
595        assert_eq!(removed.kind.as_str(), "greentic.secrets@1.0.0");
596        assert!(env.packs.is_empty());
597
598        let err = remove_pack_binding(&mut env, CapabilitySlot::Secrets).unwrap_err();
599        assert!(matches!(err, BindingError::SlotNotBound { .. }));
600    }
601
602    #[test]
603    fn rollback_pack_binding_restores_previous_once() {
604        let mut env = minimal_env();
605        env.packs
606            .push(pack(CapabilitySlot::Secrets, "greentic.secrets"));
607        update_pack_binding(
608            &mut env,
609            CapabilitySlot::Secrets,
610            pack(CapabilitySlot::Secrets, "greentic.vault"),
611        )
612        .unwrap();
613
614        let (restored, generation) =
615            rollback_pack_binding(&mut env, CapabilitySlot::Secrets).expect("stash restores");
616        assert_eq!(generation, 2, "rollback advances generation");
617        assert_eq!(restored.kind.as_str(), "greentic.secrets@1.0.0");
618        assert!(
619            restored.previous_binding_ref.is_none(),
620            "restored ref cleared — single-step only"
621        );
622
623        let err = rollback_pack_binding(&mut env, CapabilitySlot::Secrets).unwrap_err();
624        assert_eq!(
625            err.to_string(),
626            "slot `secrets` on env `local` has no previous binding to roll back to"
627        );
628    }
629
630    #[test]
631    fn pack_stash_is_bounded_to_one_level_across_multiple_updates() {
632        let mut env = minimal_env();
633        env.packs.push(pack(CapabilitySlot::Secrets, "greentic.a"));
634        update_pack_binding(
635            &mut env,
636            CapabilitySlot::Secrets,
637            pack(CapabilitySlot::Secrets, "greentic.b"),
638        )
639        .unwrap();
640        let after_second = env.packs[0]
641            .previous_binding_ref
642            .clone()
643            .expect("stash present");
644        update_pack_binding(
645            &mut env,
646            CapabilitySlot::Secrets,
647            pack(CapabilitySlot::Secrets, "greentic.c"),
648        )
649        .unwrap();
650        let after_third = env.packs[0]
651            .previous_binding_ref
652            .clone()
653            .expect("stash present");
654
655        let decoded = inline_stash::load_inline(&after_third).expect("stash decodes");
656        assert!(
657            decoded.get("previous_binding_ref").is_none(),
658            "stashed snapshot must not nest the prior stash"
659        );
660        // Token length stays flat (identical payload sizes) rather than
661        // growing by the previous token's length.
662        let len = |p: &std::path::Path| p.as_os_str().len();
663        assert!(
664            len(&after_third) <= len(&after_second) + 8,
665            "stash grew: {} -> {}",
666            len(&after_second),
667            len(&after_third)
668        );
669    }
670
671    // --- extension family ---
672
673    #[test]
674    fn add_extension_binding_distinguishes_instances() {
675        let mut env = minimal_env();
676        add_extension_binding(&mut env, ext("greentic.memory", None)).expect("default instance");
677        add_extension_binding(&mut env, ext("greentic.memory", Some("alt")))
678            .expect("named instance coexists with default");
679        assert_eq!(env.extensions.len(), 2);
680
681        let err = add_extension_binding(&mut env, ext("greentic.memory", None)).unwrap_err();
682        assert_eq!(
683            err.to_string(),
684            "extension `greentic.memory` is already bound on env `local`; use update"
685        );
686        assert_eq!(env.extensions.len(), 2, "env untouched on Err");
687    }
688
689    #[test]
690    fn update_extension_binding_bumps_generation_and_stashes_previous() {
691        let mut env = minimal_env();
692        env.extensions.push(ext("greentic.memory", Some("alt")));
693        let key = ExtensionKey::new("greentic.memory", Some("alt".to_string()));
694        let (updated, generation) = update_extension_binding(
695            &mut env,
696            &key,
697            ExtensionBinding {
698                pack_ref: PackId::new("greentic.memory-v2"),
699                ..ext("greentic.memory", Some("alt"))
700            },
701        )
702        .expect("bound key updates");
703        assert_eq!(generation, 1);
704        assert_eq!(updated.pack_ref.as_str(), "greentic.memory-v2");
705        let stash = updated.previous_binding_ref.expect("prior binding stashed");
706        let prev = inline_stash::load_inline(&stash).expect("stash decodes");
707        assert_eq!(prev["pack_ref"], "greentic.memory");
708    }
709
710    #[test]
711    fn update_extension_binding_rejects_unbound_key() {
712        let mut env = minimal_env();
713        let key = ExtensionKey::new("greentic.memory", None);
714        let err =
715            update_extension_binding(&mut env, &key, ext("greentic.memory", None)).unwrap_err();
716        assert_eq!(
717            err.to_string(),
718            "extension `greentic.memory` not bound on env `local`"
719        );
720    }
721
722    #[test]
723    fn update_extension_binding_rejects_key_mismatch() {
724        let mut env = minimal_env();
725        env.extensions.push(ext("greentic.memory", None));
726        let key = ExtensionKey::new("greentic.memory", None);
727        // The replacement binding carries a different instance_id.
728        let err = update_extension_binding(&mut env, &key, ext("greentic.memory", Some("alt")))
729            .unwrap_err();
730        assert!(
731            matches!(err, BindingError::ExtensionKeyMismatch { .. }),
732            "expected ExtensionKeyMismatch, got: {err:?}"
733        );
734        assert_eq!(
735            err.to_string(),
736            "binding key `greentic.memory/alt` does not match target key `greentic.memory`"
737        );
738        assert_eq!(env.extensions.len(), 1, "env untouched on Err");
739        assert_eq!(env.extensions[0].generation, 0, "generation untouched");
740    }
741
742    #[test]
743    fn remove_extension_binding_returns_removed_binding() {
744        let mut env = minimal_env();
745        env.extensions.push(ExtensionBinding {
746            generation: 2,
747            ..ext("greentic.memory", None)
748        });
749        let key = ExtensionKey::new("greentic.memory", None);
750        let (removed, generation) =
751            remove_extension_binding(&mut env, &key).expect("bound key removes");
752        assert_eq!(generation, 2);
753        assert_eq!(removed.pack_ref.as_str(), "greentic.memory");
754        assert!(env.extensions.is_empty());
755    }
756
757    #[test]
758    fn rollback_extension_binding_restores_previous_once() {
759        let mut env = minimal_env();
760        env.extensions.push(ext("greentic.memory", None));
761        let key = ExtensionKey::new("greentic.memory", None);
762        update_extension_binding(
763            &mut env,
764            &key,
765            ExtensionBinding {
766                pack_ref: PackId::new("greentic.memory-v2"),
767                ..ext("greentic.memory", None)
768            },
769        )
770        .unwrap();
771
772        let (restored, generation) =
773            rollback_extension_binding(&mut env, &key).expect("stash restores");
774        assert_eq!(generation, 2);
775        assert_eq!(restored.pack_ref.as_str(), "greentic.memory");
776        assert!(restored.previous_binding_ref.is_none());
777
778        let err = rollback_extension_binding(&mut env, &key).unwrap_err();
779        assert_eq!(
780            err.to_string(),
781            "extension `greentic.memory` on env `local` has no previous binding to roll back to"
782        );
783    }
784
785    #[test]
786    fn extension_stash_is_bounded_to_one_level_across_multiple_updates() {
787        let mut env = minimal_env();
788        env.extensions.push(ext("greentic.memory", None));
789        let key = ExtensionKey::new("greentic.memory", None);
790        for _ in 0..3 {
791            update_extension_binding(&mut env, &key, ext("greentic.memory", None)).unwrap();
792        }
793        let stash = env.extensions[0]
794            .previous_binding_ref
795            .clone()
796            .expect("stash present");
797        let decoded = inline_stash::load_inline(&stash).expect("stash decodes");
798        assert!(
799            decoded.get("previous_binding_ref").is_none(),
800            "stashed snapshot must not nest the prior stash"
801        );
802    }
803
804    // --- wire-format pinning (PR-3b client encoding) ---
805
806    #[test]
807    fn pack_binding_payload_wire_format() {
808        let payload = PackBindingPayload {
809            binding: pack(CapabilitySlot::Secrets, "greentic.secrets"),
810        };
811        let json = serde_json::to_value(&payload).unwrap();
812        assert_eq!(json["binding"]["slot"], "secrets");
813        assert_eq!(json["binding"]["kind"], "greentic.secrets@1.0.0");
814        let back: PackBindingPayload = serde_json::from_value(json).unwrap();
815        assert_eq!(back.binding, payload.binding);
816    }
817
818    #[test]
819    fn extension_keyed_payload_wire_format() {
820        // Keyed verbs without a binding: the field is absent, the
821        // (explicitly null) instance_id is present — exactly what the
822        // PR-3b client's `WireExtensionKey` emitted.
823        let keyed = ExtensionKeyedPayload {
824            key: ExtensionKey::new("greentic.memory", None),
825            binding: None,
826        };
827        let json = serde_json::to_value(&keyed).unwrap();
828        assert_eq!(json["key"]["kind_path"], "greentic.memory");
829        assert!(json["key"]["instance_id"].is_null());
830        assert!(json.get("binding").is_none(), "absent when None");
831
832        let back: ExtensionKeyedPayload =
833            serde_json::from_value(serde_json::json!({"key": {"kind_path": "greentic.memory"}}))
834                .unwrap();
835        assert_eq!(back.key.instance_id, None, "absent instance_id decodes");
836        assert!(back.binding.is_none());
837    }
838
839    #[test]
840    fn binding_generation_outcome_wire_format() {
841        let outcome = BindingGenerationOutcome {
842            binding: ext("greentic.memory", Some("alt")),
843            generation: 4,
844        };
845        let json = serde_json::to_value(&outcome).unwrap();
846        assert_eq!(json["generation"], 4);
847        assert_eq!(json["binding"]["instance_id"], "alt");
848        let back: BindingGenerationOutcome<ExtensionBinding> =
849            serde_json::from_value(json).unwrap();
850        assert_eq!(back.binding, outcome.binding);
851        assert_eq!(back.generation, 4);
852    }
853}