Skip to main content

greentic_deploy_spec/
engine.rs

1//! Pure environment-verb semantics shared by every `EnvironmentMutations`
2//! backend (Phase D PR-4.2a).
3//!
4//! `LocalFsStore` (in `greentic-deployer`) and the operator-store-server's
5//! HTTP handlers must apply byte-identical transforms for the same verb —
6//! two hand-maintained copies of "what `op env update` means" WILL drift.
7//! This module is the single source of those semantics: pure
8//! `Environment → Environment` transforms with no I/O, no clock, and no
9//! key material. Each backend supplies storage (flock'd JSON file vs.
10//! SQLite CAS row) around the same call.
11//!
12//! The payload structs here double as the A8 wire DTOs: they derive
13//! `Serialize`/`Deserialize` in exactly the JSON shape the PR-3b
14//! `HttpEnvironmentStore` client established (see the wire-format tests at
15//! the bottom — they pin the encoding). Verb groups migrate here one slice
16//! at a time alongside their server routes; this file starts with the
17//! environment-lifecycle group (`create` / `update` / `migrate-bindings`).
18//!
19//! FS-coupled verb steps (revenue-policy sidecar signing, operator-key
20//! loading, trust-root files, ID minting) are deliberately NOT here — they
21//! stay behind injected seams in each backend.
22
23use serde::{Deserialize, Serialize};
24use thiserror::Error;
25
26use crate::environment::{EnvPackBinding, Environment, EnvironmentHostConfig, ExtensionBinding};
27use crate::retention::{HealthStatus, RetentionPolicy, RevocationConfig};
28use crate::version::SchemaVersion;
29use greentic_types::EnvId;
30
31/// Revision-lifecycle verb group (PR-4.2b). Re-exported flat so call sites
32/// use `engine::stage_revision` etc. like the env-lifecycle group above.
33pub mod revisions;
34pub use revisions::*;
35
36/// Inline one-step-rollback stash scheme (`inline://` base64 tokens),
37/// shared by the traffic group here and the deployer CLI's pack/extension
38/// binding verbs.
39pub mod inline_stash;
40
41/// Traffic-split verb group (PR-4.2c). Re-exported flat like the groups
42/// above.
43pub mod traffic;
44pub use traffic::*;
45
46/// Pack/extension-binding verb group (PR-4.2d). Re-exported flat like the
47/// groups above.
48pub mod bindings;
49pub use bindings::*;
50
51/// Trust-root verb group wire shapes (PR-4.2f). Shapes only — the pure
52/// transforms need crypto and live in `greentic-operator-trust`.
53pub mod trust_root;
54pub use trust_root::*;
55
56/// Bundle-deployment verb group (PR-4.2g). Re-exported flat like the
57/// groups above. The revenue-policy signing step stays behind each
58/// backend's seam (`greentic-operator-trust::revenue_policy`).
59pub mod bundles;
60pub use bundles::*;
61
62/// Messaging-endpoint verb group (PR-4.2h). Re-exported flat like the
63/// groups above. The webhook-secret minting step stays behind each
64/// backend's `provision` closure seam (see the module doc).
65pub mod messaging;
66pub use messaging::*;
67
68/// Failures produced by pure verb transforms. Each backend maps these onto
69/// its own error surface: `LocalFsStore` → `StoreError`,
70/// the operator-store-server → [`crate::remote::RemoteStoreError`].
71#[derive(Debug, Clone, PartialEq, Eq, Error)]
72pub enum EngineError {
73    /// The target environment does not exist and the verb's payload did not
74    /// authorize creating it.
75    #[error("environment `{0}` not found")]
76    NotFound(EnvId),
77}
78
79// ---------------------------------------------------------------------------
80// FieldUpdate — tri-state patch field (moved from greentic-deployer
81// `environment::mutations` in PR-4.2a; semantics unchanged)
82// ---------------------------------------------------------------------------
83
84/// Tri-state field for [`UpdateEnvironmentPayload`]: callers can keep the
85/// existing value, set a new one, or clear an optional field back to `None`.
86///
87/// `Keep` maps to the prior `None` behavior (no change). `Set(v)` maps to
88/// the prior `Some(v)`. `Clear` writes `None` into the persisted field,
89/// which a plain `Option<T>` patch shape could not express.
90///
91/// # Wire format
92///
93/// Serde mirrors the A8 wire encoding established by the PR-3b client:
94/// `Set(v)` is `{"value": v}`, `Clear` is `{"clear": true}`, and `Keep` is
95/// the **absent field** (every payload field carries
96/// `#[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]`).
97/// A present-but-`{"clear": false}` value deserializes to `Keep` — the
98/// caller said "don't clear" and named no new value.
99///
100/// Deserialization is **strict**: a body carrying both `value` and `clear`
101/// keys is rejected (contradictory patch intent), and unknown keys are
102/// rejected via `deny_unknown_fields`. `{"value": null}` is treated as
103/// absent for `T` that deserializes from null.
104#[derive(Debug, Clone, Default, PartialEq, Eq)]
105pub enum FieldUpdate<T> {
106    /// Leave the existing value unchanged (the prior `None` behavior).
107    #[default]
108    Keep,
109    /// Write a new value.
110    Set(T),
111    /// Clear an optional field back to `None`. Only valid for fields that
112    /// are `Option<T>` on the persisted struct.
113    Clear,
114}
115
116impl<T> FieldUpdate<T> {
117    /// Convert from `Option<T>` (backward-compat): `None` → `Keep`,
118    /// `Some(v)` → `Set(v)`. Existing callers that pass bare `None` keep
119    /// the prior semantics with no code change beyond wrapping.
120    pub fn from_option(opt: Option<T>) -> Self {
121        match opt {
122            Some(v) => Self::Set(v),
123            None => Self::Keep,
124        }
125    }
126
127    /// Convert from `Option<Option<T>>` (JSON tri-state): outer `None` →
128    /// `Keep`, `Some(None)` → `Clear`, `Some(Some(v))` → `Set(v)`.
129    pub fn from_double_option(opt: Option<Option<T>>) -> Self {
130        match opt {
131            None => Self::Keep,
132            Some(None) => Self::Clear,
133            Some(Some(v)) => Self::Set(v),
134        }
135    }
136
137    /// Whether this update is a no-op.
138    pub fn is_keep(&self) -> bool {
139        matches!(self, Self::Keep)
140    }
141
142    /// Apply this update to an `Option<T>` target field: `Keep` is a no-op,
143    /// `Set(v)` writes `Some(v)`, `Clear` writes `None`.
144    pub fn apply_to(self, target: &mut Option<T>) {
145        match self {
146            Self::Keep => {}
147            Self::Set(v) => *target = Some(v),
148            Self::Clear => *target = None,
149        }
150    }
151}
152
153/// Private serde representation for `Serialize` only: `{"value": v}` for
154/// `Set`, `{"clear": true}` for `Clear`. Deserialization uses a strict
155/// `deny_unknown_fields` helper below.
156#[derive(Serialize)]
157#[serde(untagged)]
158enum FieldUpdateRepr<T> {
159    Set { value: T },
160    Clear { clear: bool },
161}
162
163/// Strict deserialization helper — `deny_unknown_fields` rejects payloads
164/// carrying both `value` and `clear` (or any unknown key).
165#[derive(Deserialize)]
166#[serde(deny_unknown_fields)]
167struct FieldUpdateDeHelper<T> {
168    value: Option<T>,
169    clear: Option<bool>,
170}
171
172impl<T: Serialize> Serialize for FieldUpdate<T> {
173    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
174        match self {
175            // Reachable only when a container forgets `skip_serializing_if`;
176            // `null` round-trips back to `Keep` below.
177            Self::Keep => serializer.serialize_none(),
178            Self::Set(v) => FieldUpdateRepr::Set { value: v }.serialize(serializer),
179            Self::Clear => FieldUpdateRepr::<&T>::Clear { clear: true }.serialize(serializer),
180        }
181    }
182}
183
184impl<'de, T: Deserialize<'de>> Deserialize<'de> for FieldUpdate<T> {
185    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
186        // Outer `Option` maps JSON `null` (or absent field via `#[serde(default)]`)
187        // to `Keep`. The inner helper is `deny_unknown_fields`, so
188        // `{"value":…,"clear":…}` or any unknown key raises a typed error.
189        //
190        // NOTE: `{"value": null}` is indistinguishable from a missing `value`
191        // key for `T` that deserializes from null — treated as absent. Do not
192        // over-engineer: document this edge case and move on.
193        match Option::<FieldUpdateDeHelper<T>>::deserialize(deserializer)? {
194            None => Ok(Self::Keep),
195            Some(h) => match (h.value, h.clear) {
196                (Some(_), Some(_)) => Err(serde::de::Error::custom(
197                    "field update cannot carry both `value` and `clear`",
198                )),
199                (Some(v), None) => Ok(Self::Set(v)),
200                (None, Some(true)) => Ok(Self::Clear),
201                (None, Some(false)) => Ok(Self::Keep),
202                (None, None) => Err(serde::de::Error::custom(
203                    "field update object must carry `value` or `clear`",
204                )),
205            },
206        }
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Verb payloads (wire DTOs)
212// ---------------------------------------------------------------------------
213
214/// Inputs to `EnvironmentMutations::create_environment`, and the A8
215/// `POST /environments` request body. `env_id` rides in the body (not the
216/// URL) because the resource does not exist yet.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct CreateEnvironmentPayload {
219    pub env_id: EnvId,
220    pub name: String,
221    pub host_config: EnvironmentHostConfig,
222}
223
224/// Optional-field patch for `EnvironmentMutations::update_environment`, and
225/// the A8 `PATCH /environments/{env_id}` request body. Replaces the earlier
226/// `set_public_url` and `set_config` verbs — both were strict subsets of
227/// this patch shape, so collapsing them removes two HTTP endpoints and two
228/// impl bodies that would drift over time.
229///
230/// Required fields (`name`) stay `Option<T>` — `None` = keep, `Some(v)` =
231/// set. Optional fields (`region`, `tenant_org_id`, `listen_addr`,
232/// `public_base_url`) use [`FieldUpdate<T>`] so callers can distinguish
233/// Keep / Set / Clear.
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct UpdateEnvironmentPayload {
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub name: Option<String>,
238    #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
239    pub region: FieldUpdate<String>,
240    #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
241    pub tenant_org_id: FieldUpdate<String>,
242    #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
243    pub listen_addr: FieldUpdate<std::net::SocketAddr>,
244    #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
245    pub public_base_url: FieldUpdate<String>,
246    /// Webchat GUI toggle. `Keep` leaves the existing value; `Set(b)` writes an
247    /// explicit choice; `Clear` reverts to the env-id default (see
248    /// [`EnvironmentHostConfig::resolved_gui_enabled`]).
249    #[serde(default, skip_serializing_if = "FieldUpdate::is_keep")]
250    pub gui_enabled: FieldUpdate<bool>,
251}
252
253/// Optional seed payload for `EnvironmentMutations::migrate_merge_bindings`.
254/// Supplied when the caller wants the impl to atomically create the target
255/// env (using these fields) if it doesn't exist yet, then merge the bindings
256/// into it. Mirrors the seed-from-source behavior of `op env migrate-dev`
257/// where the source's host config + policy state ride along onto the freshly
258/// created target.
259///
260/// `name` is intentionally omitted: the impl derives it from the target
261/// env id. `schema` is set to the current `ENVIRONMENT_V1` constant by the
262/// impl, not threaded through the wire.
263#[derive(Debug, Clone, Serialize, Deserialize)]
264pub struct MigrateSeedPayload {
265    pub host_config: EnvironmentHostConfig,
266    pub revocation: RevocationConfig,
267    pub retention: RetentionPolicy,
268    pub health: HealthStatus,
269}
270
271/// Inputs to `EnvironmentMutations::migrate_merge_bindings`, and the A8
272/// `POST /environments/{env_id}/migrate-bindings` request body.
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct MigrateMergePayload {
275    pub packs: Vec<EnvPackBinding>,
276    pub extensions: Vec<ExtensionBinding>,
277    /// When `Some`, the impl atomically creates the target env (using
278    /// these fields) if it doesn't exist yet, then merges the bindings
279    /// into it. When `None`, the impl returns not-found if the target
280    /// doesn't exist — the caller is asserting target presence.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub seed_if_missing: Option<MigrateSeedPayload>,
283}
284
285/// Result of [`merge_bindings`], and the A8 migrate-bindings response body
286/// (`merged_slots` / `merged_extensions` keys pinned by the PR-3b client).
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct MergeReport {
289    pub merged_slots: Vec<String>,
290    pub merged_extensions: Vec<String>,
291}
292
293// ---------------------------------------------------------------------------
294// ExtensionKey (moved from greentic-deployer `environment::mutations` in
295// PR-4.2a; semantics unchanged)
296// ---------------------------------------------------------------------------
297
298/// `(kind_path, instance_id)` composite key identifying one extension binding
299/// in `Environment::extensions`. `kind_path` is the canonical
300/// `ExtensionKind::path()` form (e.g. `"capability/memory/long-term"`).
301///
302/// `instance_id` is `Option<String>`: a `None` binding (the unnamed default)
303/// and a `Some("default")` binding on the same `kind_path` are **distinct**
304/// and may coexist — two `None` bindings on the same path collide.
305/// This mirrors [`ExtensionBinding::instance_id`].
306///
307/// Serde shape (PR-4.2d): the key rides inside
308/// [`ExtensionKeyedPayload`](crate::engine::ExtensionKeyedPayload) on the
309/// A8 wire as `{"kind_path": …, "instance_id": …}` — `instance_id` is
310/// serialized explicitly (`null` for `None`, matching the PR-3b client's
311/// encoding) and tolerated absent on deserialize.
312#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
313pub struct ExtensionKey {
314    pub kind_path: String,
315    #[serde(default)]
316    pub instance_id: Option<String>,
317}
318
319impl ExtensionKey {
320    pub fn new(kind_path: impl Into<String>, instance_id: Option<String>) -> Self {
321        Self {
322            kind_path: kind_path.into(),
323            instance_id,
324        }
325    }
326
327    /// Derive the key from an existing [`ExtensionBinding`], mirroring the
328    /// `(descriptor-path, instance_id)` convention in the deployer CLI.
329    pub fn from_binding(b: &ExtensionBinding) -> Self {
330        Self {
331            kind_path: b.kind.path().to_string(),
332            instance_id: b.instance_id.clone(),
333        }
334    }
335
336    /// Whether `b` carries this `(kind_path, instance_id)` key. Borrowed
337    /// comparison — no allocation per element, so it's cheap inside a scan.
338    pub fn matches(&self, b: &ExtensionBinding) -> bool {
339        b.kind.path() == self.kind_path && b.instance_id.as_deref() == self.instance_id.as_deref()
340    }
341}
342
343/// Wire-stable rendering used by audit-event targets and CLI outcome JSON.
344/// `<kind_path>/<instance_id>` when an instance is present, otherwise just
345/// `<kind_path>`. Mirrors the deployer CLI's Display so existing
346/// operator-facing strings stay byte-identical.
347impl std::fmt::Display for ExtensionKey {
348    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
349        match &self.instance_id {
350            Some(inst) => write!(f, "{}/{}", self.kind_path, inst),
351            None => f.write_str(&self.kind_path),
352        }
353    }
354}
355
356// ---------------------------------------------------------------------------
357// Pure transforms — environment-lifecycle verb group
358// ---------------------------------------------------------------------------
359
360/// Build an empty [`Environment`] at the current `ENVIRONMENT_V1` schema
361/// with the supplied `host_config` + policy state. All collection fields
362/// start empty and `credentials_ref` is `None` — populated downstream by
363/// the binding verbs. Shared by `create_environment` (which passes
364/// `Default::default()` for revocation/retention/health) and
365/// `migrate_merge_bindings`' seed branch (which threads the source's
366/// existing policy state through).
367///
368/// The caller's [`EnvironmentHostConfig::env_id`] is overwritten with
369/// `env_id` so the persisted row's host-config envelope cannot disagree
370/// with the key it lands under.
371///
372/// Centralizing this prevents seed sites from drifting when a new
373/// `Environment` field lands — every site must zero/default it, and
374/// missing one site is the silent-zero-value footgun.
375pub fn fresh_environment(
376    env_id: &EnvId,
377    name: String,
378    host_config: EnvironmentHostConfig,
379    revocation: RevocationConfig,
380    retention: RetentionPolicy,
381    health: HealthStatus,
382) -> Environment {
383    Environment {
384        schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
385        environment_id: env_id.clone(),
386        name,
387        host_config: EnvironmentHostConfig {
388            env_id: env_id.clone(),
389            ..host_config
390        },
391        packs: Vec::new(),
392        credentials_ref: None,
393        bundles: Vec::new(),
394        revisions: Vec::new(),
395        traffic_splits: Vec::new(),
396        messaging_endpoints: Vec::new(),
397        extensions: Vec::new(),
398        revocation,
399        retention,
400        health,
401    }
402}
403
404/// Apply an [`UpdateEnvironmentPayload`] patch to an existing env in place:
405/// `Keep` fields are skipped, `Set` writes the new value, `Clear` resets an
406/// optional field to `None`. Infallible — field-level validation (URL shape,
407/// etc.) happens at the CLI/handler boundary before the payload is built.
408pub fn apply_environment_update(env: &mut Environment, patch: UpdateEnvironmentPayload) {
409    if let Some(name) = patch.name {
410        env.name = name;
411    }
412    patch.region.apply_to(&mut env.host_config.region);
413    patch
414        .tenant_org_id
415        .apply_to(&mut env.host_config.tenant_org_id);
416    patch.listen_addr.apply_to(&mut env.host_config.listen_addr);
417    patch
418        .public_base_url
419        .apply_to(&mut env.host_config.public_base_url);
420    patch.gui_enabled.apply_to(&mut env.host_config.gui_enabled);
421}
422
423/// Resolve the migrate-bindings target: an existing env passes through; a
424/// missing env is seeded from `seed` (name derived from `env_id`) or — when
425/// the caller asserted presence by passing `seed: None` — rejected with
426/// [`EngineError::NotFound`].
427pub fn seed_or_existing(
428    existing: Option<Environment>,
429    env_id: &EnvId,
430    seed: Option<MigrateSeedPayload>,
431) -> Result<Environment, EngineError> {
432    match existing {
433        Some(env) => Ok(env),
434        None => match seed {
435            Some(seed) => Ok(fresh_environment(
436                env_id,
437                env_id.as_str().to_string(),
438                seed.host_config,
439                seed.revocation,
440                seed.retention,
441                seed.health,
442            )),
443            None => Err(EngineError::NotFound(env_id.clone())),
444        },
445    }
446}
447
448/// Merge pack bindings and extension bindings into `env`, skipping slots
449/// already in `env.packs` and extension keys already in `env.extensions`
450/// (uniqueness on `(kind_path, instance_id)`). Returns the merged names.
451///
452/// `messaging_endpoints` are NOT merged by this verb: they reference
453/// `linked_bundles` that don't migrate, so a blind copy would break
454/// referential integrity.
455pub fn merge_bindings(
456    env: &mut Environment,
457    packs: Vec<EnvPackBinding>,
458    extensions: Vec<ExtensionBinding>,
459) -> MergeReport {
460    let mut merged_slots = Vec::new();
461    for binding in packs {
462        if env.packs.iter().any(|b| b.slot == binding.slot) {
463            continue;
464        }
465        merged_slots.push(binding.slot.to_string());
466        env.packs.push(binding);
467    }
468    let mut merged_extensions = Vec::new();
469    for ext in extensions {
470        let key = ExtensionKey::from_binding(&ext);
471        if env.extensions.iter().any(|e| key.matches(e)) {
472            continue;
473        }
474        merged_extensions.push(key.to_string());
475        env.extensions.push(ext);
476    }
477    MergeReport {
478        merged_slots,
479        merged_extensions,
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    use super::*;
486    use serde_json::json;
487
488    fn env_id() -> EnvId {
489        EnvId::try_from("local").unwrap()
490    }
491
492    fn host_config() -> EnvironmentHostConfig {
493        EnvironmentHostConfig {
494            env_id: env_id(),
495            region: None,
496            tenant_org_id: None,
497            listen_addr: None,
498            public_base_url: None,
499            gui_enabled: None,
500        }
501    }
502
503    fn minimal_env() -> Environment {
504        fresh_environment(
505            &env_id(),
506            "local".to_string(),
507            host_config(),
508            RevocationConfig::default(),
509            RetentionPolicy::default(),
510            HealthStatus::default(),
511        )
512    }
513
514    // --- FieldUpdate semantics (moved from greentic-deployer) ---
515
516    #[test]
517    fn field_update_default_is_keep() {
518        assert!(FieldUpdate::<String>::default().is_keep());
519    }
520
521    #[test]
522    fn field_update_from_option_maps_none_to_keep_and_some_to_set() {
523        assert_eq!(FieldUpdate::from_option(None::<u32>), FieldUpdate::Keep);
524        assert_eq!(FieldUpdate::from_option(Some(7)), FieldUpdate::Set(7));
525    }
526
527    #[test]
528    fn field_update_from_double_option_maps_tristate() {
529        assert_eq!(
530            FieldUpdate::from_double_option(None::<Option<u32>>),
531            FieldUpdate::Keep
532        );
533        assert_eq!(
534            FieldUpdate::from_double_option(Some(None::<u32>)),
535            FieldUpdate::Clear
536        );
537        assert_eq!(
538            FieldUpdate::from_double_option(Some(Some(7))),
539            FieldUpdate::Set(7)
540        );
541    }
542
543    #[test]
544    fn field_update_apply_to_covers_all_arms() {
545        let mut target = Some("old".to_string());
546        FieldUpdate::Keep.apply_to(&mut target);
547        assert_eq!(target.as_deref(), Some("old"));
548        FieldUpdate::Set("new".to_string()).apply_to(&mut target);
549        assert_eq!(target.as_deref(), Some("new"));
550        FieldUpdate::<String>::Clear.apply_to(&mut target);
551        assert_eq!(target, None);
552        // Clear on already-None is a no-op.
553        FieldUpdate::<String>::Clear.apply_to(&mut target);
554        assert_eq!(target, None);
555    }
556
557    #[test]
558    fn update_environment_payload_defaults_to_all_keep() {
559        let payload = UpdateEnvironmentPayload::default();
560        assert!(payload.name.is_none());
561        assert!(payload.region.is_keep());
562        assert!(payload.tenant_org_id.is_keep());
563        assert!(payload.listen_addr.is_keep());
564        assert!(payload.public_base_url.is_keep());
565    }
566
567    #[test]
568    fn extension_key_identity_distinguishes_none_from_named_instance() {
569        let key = ExtensionKey::new("capability/memory/long-term", Some("default".to_string()));
570        assert_eq!(key.to_string(), "capability/memory/long-term/default");
571
572        // Hashable + Eq for `(kind_path, instance_id)` lookup keys. `None`
573        // (the unnamed default) and `Some("default")` on the SAME path are
574        // distinct identities that coexist.
575        let unnamed = ExtensionKey::new("capability/memory/long-term", None);
576        assert_eq!(unnamed.to_string(), "capability/memory/long-term");
577        assert_ne!(unnamed, key, "None and Some(_) must differ");
578
579        let mut set = std::collections::HashSet::new();
580        set.insert(key.clone());
581        assert!(set.contains(&key));
582        assert!(
583            !set.contains(&unnamed),
584            "None key must not hash-collide with Some(_) key"
585        );
586        set.insert(unnamed);
587        assert_eq!(set.len(), 2);
588    }
589
590    // --- Wire-format pinning (must match the PR-3b client encoding) ---
591
592    #[test]
593    fn update_payload_wire_format_is_pinned() {
594        // Set → {"value": v}; Clear → {"clear": true}; Keep → absent.
595        let payload = UpdateEnvironmentPayload {
596            name: Some("renamed".to_string()),
597            region: FieldUpdate::Set("eu-west-1".to_string()),
598            tenant_org_id: FieldUpdate::Clear,
599            listen_addr: FieldUpdate::Keep,
600            public_base_url: FieldUpdate::Keep,
601            gui_enabled: FieldUpdate::Keep,
602        };
603        assert_eq!(
604            serde_json::to_value(&payload).unwrap(),
605            json!({
606                "name": "renamed",
607                "region": {"value": "eu-west-1"},
608                "tenant_org_id": {"clear": true},
609            })
610        );
611    }
612
613    #[test]
614    fn update_payload_deserializes_tristate() {
615        let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
616            "region": {"value": "eu-west-1"},
617            "tenant_org_id": {"clear": true},
618        }))
619        .unwrap();
620        assert_eq!(payload.name, None);
621        assert_eq!(payload.region, FieldUpdate::Set("eu-west-1".to_string()));
622        assert_eq!(payload.tenant_org_id, FieldUpdate::Clear);
623        assert!(payload.listen_addr.is_keep());
624        assert!(payload.public_base_url.is_keep());
625    }
626
627    #[test]
628    fn field_update_clear_false_and_null_deserialize_to_keep() {
629        let payload: UpdateEnvironmentPayload = serde_json::from_value(json!({
630            "region": {"clear": false},
631            "tenant_org_id": null,
632        }))
633        .unwrap();
634        assert!(payload.region.is_keep());
635        assert!(payload.tenant_org_id.is_keep());
636    }
637
638    #[test]
639    fn field_update_rejects_contradictory_value_and_clear() {
640        let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
641            "region": {"value": "x", "clear": true},
642        }))
643        .unwrap_err();
644        assert!(
645            err.to_string().contains("cannot carry both"),
646            "expected contradictory-field rejection: {err}"
647        );
648    }
649
650    #[test]
651    fn field_update_rejects_unknown_keys() {
652        let err = serde_json::from_value::<UpdateEnvironmentPayload>(json!({
653            "region": {"unknown_key": 1},
654        }))
655        .unwrap_err();
656        assert!(
657            err.to_string().contains("unknown field"),
658            "expected unknown-key rejection: {err}"
659        );
660    }
661
662    #[test]
663    fn create_payload_round_trips() {
664        let payload = CreateEnvironmentPayload {
665            env_id: env_id(),
666            name: "local".to_string(),
667            host_config: host_config(),
668        };
669        let value = serde_json::to_value(&payload).unwrap();
670        assert_eq!(value["env_id"], "local");
671        assert_eq!(value["name"], "local");
672        let back: CreateEnvironmentPayload = serde_json::from_value(value).unwrap();
673        assert_eq!(back.env_id, payload.env_id);
674        assert_eq!(back.name, payload.name);
675    }
676
677    #[test]
678    fn migrate_payload_omits_absent_seed() {
679        let payload = MigrateMergePayload {
680            packs: Vec::new(),
681            extensions: Vec::new(),
682            seed_if_missing: None,
683        };
684        let value = serde_json::to_value(&payload).unwrap();
685        assert!(value.get("seed_if_missing").is_none());
686        let back: MigrateMergePayload =
687            serde_json::from_value(json!({"packs": [], "extensions": []})).unwrap();
688        assert!(back.seed_if_missing.is_none());
689    }
690
691    // --- Transform semantics ---
692
693    #[test]
694    fn fresh_environment_pins_host_config_env_id() {
695        let other = EnvId::try_from("other").unwrap();
696        let mut hc = host_config();
697        hc.env_id = other;
698        let env = fresh_environment(
699            &env_id(),
700            "local".to_string(),
701            hc,
702            RevocationConfig::default(),
703            RetentionPolicy::default(),
704            HealthStatus::default(),
705        );
706        assert_eq!(env.host_config.env_id, env_id());
707        assert_eq!(env.environment_id, env_id());
708        assert!(env.packs.is_empty() && env.bundles.is_empty());
709    }
710
711    #[test]
712    fn apply_environment_update_patches_and_clears() {
713        let mut env = minimal_env();
714        env.host_config.region = Some("us-east-1".to_string());
715        apply_environment_update(
716            &mut env,
717            UpdateEnvironmentPayload {
718                name: Some("renamed".to_string()),
719                region: FieldUpdate::Clear,
720                tenant_org_id: FieldUpdate::Set("org-1".to_string()),
721                listen_addr: FieldUpdate::Keep,
722                public_base_url: FieldUpdate::Keep,
723                gui_enabled: FieldUpdate::Keep,
724            },
725        );
726        assert_eq!(env.name, "renamed");
727        assert_eq!(env.host_config.region, None);
728        assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("org-1"));
729    }
730
731    #[test]
732    fn seed_or_existing_passes_through_seeds_and_rejects() {
733        let existing = minimal_env();
734        let out = seed_or_existing(Some(existing.clone()), &env_id(), None).unwrap();
735        assert_eq!(out.name, existing.name);
736
737        let seeded = seed_or_existing(
738            None,
739            &env_id(),
740            Some(MigrateSeedPayload {
741                host_config: host_config(),
742                revocation: RevocationConfig::default(),
743                retention: RetentionPolicy::default(),
744                health: HealthStatus::default(),
745            }),
746        )
747        .unwrap();
748        assert_eq!(seeded.name, "local");
749
750        let err = seed_or_existing(None, &env_id(), None).unwrap_err();
751        assert_eq!(err, EngineError::NotFound(env_id()));
752    }
753
754    #[test]
755    fn merge_bindings_dedups_slots_and_extension_keys() {
756        use crate::capability_slot::{CapabilitySlot, PackDescriptor};
757        use crate::ids::PackId;
758
759        let pack = |slot: CapabilitySlot| EnvPackBinding {
760            slot,
761            kind: PackDescriptor::try_new("greentic.secrets@1.0.0").unwrap(),
762            pack_ref: PackId::new("greentic.secrets"),
763            answers_ref: None,
764            generation: 0,
765            previous_binding_ref: None,
766        };
767        let ext = |instance: Option<&str>| ExtensionBinding {
768            kind: PackDescriptor::try_new("greentic.memory@0.1.0").unwrap(),
769            pack_ref: PackId::new("greentic.memory"),
770            instance_id: instance.map(str::to_string),
771            answers_ref: None,
772            generation: 0,
773            previous_binding_ref: None,
774        };
775
776        let mut env = minimal_env();
777        env.packs.push(pack(CapabilitySlot::Secrets));
778        env.extensions.push(ext(None));
779
780        let report = merge_bindings(
781            &mut env,
782            vec![pack(CapabilitySlot::Secrets), pack(CapabilitySlot::State)],
783            vec![ext(None), ext(Some("alt"))],
784        );
785
786        // Existing slot + existing (path, None) key skipped; new ones merged.
787        assert_eq!(report.merged_slots, vec!["state".to_string()]);
788        assert_eq!(
789            report.merged_extensions,
790            vec!["greentic.memory/alt".to_string()]
791        );
792        assert_eq!(env.packs.len(), 2);
793        assert_eq!(env.extensions.len(), 2);
794    }
795}