1use 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#[derive(Debug, Clone, PartialEq, Eq, Error)]
80pub enum BindingError {
81 #[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 #[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 #[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 #[error("snapshot prior binding: {detail}")]
135 SnapshotEncode { detail: String },
136 #[error("deserialise previous binding: {detail}")]
137 SnapshotDecode { detail: String },
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct PackBindingPayload {
149 pub binding: EnvPackBinding,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct ExtensionBindingPayload {
155 pub binding: ExtensionBinding,
156}
157
158#[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#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct BindingGenerationOutcome<T> {
176 pub binding: T,
177 pub generation: u64,
178}
179
180pub 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
204pub 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 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
252pub 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
264pub 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
301pub 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
324pub 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
364pub 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
376pub 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
417fn 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 #[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 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 #[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 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 #[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 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}