Skip to main content

greentic_deploy_spec/engine/
traffic.rs

1//! Pure traffic-split verb semantics (Phase D PR-4.2c).
2//!
3//! The traffic verb group (`set` / `rollback`) follows the PR-4.2a engine
4//! contract: pure `&mut Environment` transforms with no I/O, no clock
5//! (`now` is a parameter), and no key material. Both `LocalFsStore`
6//! (greentic-deployer, behind a flock) and the operator-store-server
7//! (behind SQLite CAS) drive the SAME functions, so the §5.3 admission
8//! gate, the 10,000 bps sum invariant, and the idempotency contract cannot
9//! drift between local and remote.
10//!
11//! # Persist rule (read before calling)
12//!
13//! Unlike the revision group there is no committed-on-error path here —
14//! every check runs BEFORE the single mutation at the end of each
15//! transform:
16//!
17//! - [`set_traffic_split`] `Ok` with `new_generation == Some(_)` — the env
18//!   was mutated; persist it.
19//! - [`set_traffic_split`] `Ok` with `new_generation == None` — idempotent
20//!   same-key-same-entries replay; the env was NOT touched. Do not persist
21//!   (backends with derived artifacts, e.g. `LocalFsStore`'s
22//!   `runtime-config.json`, may still reconcile them — a retry must repair
23//!   a publish that failed after the env was already durable).
24//! - [`rollback_traffic_split`] `Ok` — always a mutation; persist.
25//! - any `Err(_)` — the env was not mutated; nothing to persist.
26//!
27//! NOT here (deliberately): `runtime-config.json` materialization (a
28//! `LocalFsStore` projection of `traffic_splits` — remote consumers project
29//! it from the env document) and `TrafficSplitApplied` telemetry (emitted
30//! at the operator CLI layer from the outcome's env snapshot, so both
31//! backends produce identical events).
32//!
33//! # Wire shapes
34//!
35//! [`SetTrafficSplitPayload`] / [`RollbackTrafficSplitPayload`] double as
36//! the A8 request bodies (`POST /environments/{env}/traffic` and
37//! `POST /environments/{env}/traffic/rollback`);
38//! [`ApplyTrafficSplitOutcome`] / [`RollbackTrafficSplitOutcome`] are the
39//! response bodies. The wire-format tests at the bottom pin the encoding.
40//! The A8 `Idempotency-Key` rides the HTTP header AND
41//! [`set_traffic_split`]'s `idempotency_key` parameter — the traffic group
42//! is special in that the key is part of the domain state
43//! ([`TrafficSplit::idempotency_key`] preserves the rollback target across
44//! same-key retries), not just transport metadata.
45
46use chrono::{DateTime, Utc};
47use serde::{Deserialize, Serialize};
48use std::path::PathBuf;
49use thiserror::Error;
50
51use crate::engine::inline_stash;
52use crate::environment::Environment;
53use crate::error::SpecError;
54use crate::ids::{DeploymentId, RevisionId};
55use crate::remote::IdempotencyKey;
56use crate::revision::RevisionLifecycle;
57use crate::traffic_split::{TrafficSplit, TrafficSplitEntry};
58use crate::version::SchemaVersion;
59use greentic_types::EnvId;
60
61// ---------------------------------------------------------------------------
62// Error surface
63// ---------------------------------------------------------------------------
64
65/// Failures produced by the pure traffic-split transforms. Each backend
66/// maps these onto its own error vocabulary — `LocalFsStore` →
67/// `StoreError`, the operator-store-server →
68/// [`crate::remote::RemoteStoreError`]. Display strings are the verbatim
69/// operator-facing messages the local store has always rendered.
70/// (Not `Clone`: the [`TrafficSplitError::Invalid`] payload, [`SpecError`],
71/// isn't.)
72#[derive(Debug, PartialEq, Eq, Error)]
73pub enum TrafficSplitError {
74    /// The targeted deployment does not exist in the environment.
75    #[error("deployment `{deployment_id}` not found in env `{env_id}`")]
76    DeploymentNotFound {
77        env_id: EnvId,
78        deployment_id: DeploymentId,
79    },
80    /// An entry references a revision that does not exist (set-path
81    /// referential check — maps to the dependent-not-found noun).
82    #[error("revision `{revision_id}` not found in env `{env_id}`")]
83    RevisionNotFound {
84        env_id: EnvId,
85        revision_id: RevisionId,
86    },
87    /// An entry references a revision owned by a different deployment.
88    #[error("revision `{revision_id}` belongs to deployment `{actual}`, not `{expected}`")]
89    WrongDeployment {
90        revision_id: RevisionId,
91        actual: DeploymentId,
92        expected: DeploymentId,
93    },
94    /// A retry reused an idempotency key with different entries — a
95    /// protocol violation, not a replay.
96    #[error(
97        "idempotency key `{key}` already used for deployment `{deployment_id}` with different entries"
98    )]
99    IdempotencyKeyReused {
100        key: String,
101        deployment_id: DeploymentId,
102    },
103    /// §5.3 admission found a routed revision missing from the env. Same
104    /// message as [`TrafficSplitError::RevisionNotFound`] but a distinct
105    /// variant: on the rollback path a historical split may reference a
106    /// since-removed revision, which is a state conflict (409), not a
107    /// caller-supplied bad id (404).
108    #[error("revision `{revision_id}` not found in env `{env_id}`")]
109    AdmissionRevisionMissing {
110        env_id: EnvId,
111        revision_id: RevisionId,
112    },
113    /// §5.3 admission: only `Ready` revisions may receive traffic.
114    #[error(
115        "revision `{revision_id}` is `{lifecycle:?}`; only `Ready` revisions may receive traffic"
116    )]
117    NotReady {
118        revision_id: RevisionId,
119        lifecycle: RevisionLifecycle,
120    },
121    /// Serializing the prior split for the rollback stash failed.
122    #[error("snapshot prior split: {detail}")]
123    SnapshotEncode { detail: String },
124    /// No split exists for the deployment (rollback path).
125    #[error("no traffic split for deployment `{deployment_id}` in env `{env_id}`")]
126    NoSplit {
127        env_id: EnvId,
128        deployment_id: DeploymentId,
129    },
130    /// The live split carries no one-step-previous snapshot to restore.
131    #[error("traffic split for `{deployment_id}` has no prior version to roll back to")]
132    NoPreviousSnapshot { deployment_id: DeploymentId },
133    /// The `previous_split_ref` token failed to decode.
134    #[error("previous split payload `{}` missing", prev_ref.display())]
135    SnapshotMissing { prev_ref: PathBuf },
136    /// The decoded snapshot failed to deserialize as a [`TrafficSplit`].
137    #[error("deserialise previous split: {detail}")]
138    SnapshotDecode { detail: String },
139    /// The assembled split failed spec validation (schema discriminator /
140    /// 10,000 bps sum invariant).
141    #[error(transparent)]
142    Invalid(#[from] SpecError),
143}
144
145// ---------------------------------------------------------------------------
146// Wire payloads + outcomes
147// ---------------------------------------------------------------------------
148
149/// Inputs to `EnvironmentMutations::set_traffic_split`, and the A8
150/// `POST /environments/{env_id}/traffic` request body (field set pinned by
151/// the PR-3b client's `SetTrafficSplitRequest`). The A8 idempotency key is
152/// NOT a field — it rides the HTTP header and the engine parameter, because
153/// the traffic group persists it into [`TrafficSplit::idempotency_key`].
154#[derive(Debug, Clone, Serialize, Deserialize)]
155pub struct SetTrafficSplitPayload {
156    pub deployment_id: DeploymentId,
157    pub entries: Vec<TrafficSplitEntry>,
158    pub updated_by: String,
159    /// Audit provenance; defaults to `auth.json` when absent.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub authorization_ref: Option<String>,
162}
163
164/// `POST /environments/{env_id}/traffic/rollback` request body. The
165/// deployment id rides in the body (the route is per-env, not
166/// per-deployment) — pinned by the PR-3b client.
167#[derive(Debug, Clone, Serialize, Deserialize)]
168pub struct RollbackTrafficSplitPayload {
169    pub deployment_id: DeploymentId,
170}
171
172/// Outcome of `EnvironmentMutations::set_traffic_split`, and the A8
173/// response body. Carries the post-apply split, the generation transition
174/// for audit/observability, and the post-mutation env snapshot (the
175/// operator CLI emits `TrafficSplitApplied` telemetry from it — tenant
176/// attribution without a re-read, identical local and remote).
177///
178/// On an idempotent same-key-same-entries replay, `previous_generation`
179/// and `new_generation` are both `None` (no state change — and no
180/// telemetry).
181#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct ApplyTrafficSplitOutcome {
183    pub split: TrafficSplit,
184    pub previous_generation: Option<u64>,
185    pub new_generation: Option<u64>,
186    pub environment: Environment,
187}
188
189/// Outcome of `EnvironmentMutations::rollback_traffic_split`, and the A8
190/// response body. A rollback always advances the generation, so both
191/// generations are unconditional.
192#[derive(Debug, Clone, Serialize, Deserialize)]
193pub struct RollbackTrafficSplitOutcome {
194    pub restored: TrafficSplit,
195    pub previous_generation: u64,
196    pub new_generation: u64,
197    pub environment: Environment,
198}
199
200/// What [`set_traffic_split`] hands back to the storage layer: the applied
201/// (or replayed) split and the generation transition. The backend wraps it
202/// into [`ApplyTrafficSplitOutcome`] together with the env snapshot it just
203/// persisted.
204#[derive(Debug, Clone)]
205pub struct TrafficSplitTransition {
206    pub split: TrafficSplit,
207    pub previous_generation: Option<u64>,
208    pub new_generation: Option<u64>,
209}
210
211impl TrafficSplitTransition {
212    /// Whether the transform actually mutated the environment (`false` =
213    /// idempotent replay; see the module-level persist rule).
214    pub fn mutated(&self) -> bool {
215        self.new_generation.is_some()
216    }
217}
218
219/// What [`rollback_traffic_split`] hands back to the storage layer.
220#[derive(Debug, Clone)]
221pub struct TrafficRollbackTransition {
222    pub restored: TrafficSplit,
223    pub previous_generation: u64,
224    pub new_generation: u64,
225}
226
227// ---------------------------------------------------------------------------
228// Pure transforms
229// ---------------------------------------------------------------------------
230
231/// Replace the entire traffic-split entry list for one deployment.
232/// Validates the 10,000 bps sum invariant via [`TrafficSplit::validate`],
233/// checks §5.3 admission (entries must route to `Ready` revisions), and
234/// stashes the prior split inline for one-step rollback.
235///
236/// Idempotency: a same-key-same-entries replay (order-insensitive, see
237/// [`entries_match`]) is a no-op success with both generations `None`.
238/// Same-key-different-entries is
239/// [`TrafficSplitError::IdempotencyKeyReused`]. A new key advances the
240/// generation and snapshots the prior split as the rollback target.
241pub fn set_traffic_split(
242    env: &mut Environment,
243    payload: SetTrafficSplitPayload,
244    idempotency_key: &IdempotencyKey,
245    now: DateTime<Utc>,
246) -> Result<TrafficSplitTransition, TrafficSplitError> {
247    let SetTrafficSplitPayload {
248        deployment_id,
249        entries,
250        updated_by,
251        authorization_ref,
252    } = payload;
253    let env_id = env.environment_id.clone();
254    let deployment = env
255        .bundles
256        .iter()
257        .find(|b| b.deployment_id == deployment_id)
258        .ok_or_else(|| TrafficSplitError::DeploymentNotFound {
259            env_id: env_id.clone(),
260            deployment_id,
261        })?;
262    let bundle_id = deployment.bundle_id.clone();
263    // Revision-belongs-to-deployment check.
264    for entry in &entries {
265        let rev = env
266            .revisions
267            .iter()
268            .find(|r| r.revision_id == entry.revision_id)
269            .ok_or_else(|| TrafficSplitError::RevisionNotFound {
270                env_id: env_id.clone(),
271                revision_id: entry.revision_id,
272            })?;
273        if rev.deployment_id != deployment_id {
274            return Err(TrafficSplitError::WrongDeployment {
275                revision_id: entry.revision_id,
276                actual: rev.deployment_id,
277                expected: deployment_id,
278            });
279        }
280    }
281    // Idempotency check: a retry with the same key against the same
282    // (deployment, entries) is a no-op success; same key + different
283    // payload is a conflict; new key advances generation.
284    let prev_split_idx = env
285        .traffic_splits
286        .iter()
287        .position(|s| s.deployment_id == deployment_id);
288    if let Some(idx) = prev_split_idx {
289        let prev = &env.traffic_splits[idx];
290        if prev.idempotency_key == idempotency_key.as_str() {
291            if entries_match(&prev.entries, &entries) {
292                return Ok(TrafficSplitTransition {
293                    split: prev.clone(),
294                    previous_generation: None,
295                    new_generation: None,
296                });
297            }
298            return Err(TrafficSplitError::IdempotencyKeyReused {
299                key: idempotency_key.as_str().to_string(),
300                deployment_id,
301            });
302        }
303    }
304    // §5.3 admission, on the apply path only: the idempotent no-op replay
305    // above must stay a success even if a routed revision later drains, so
306    // a stale split is never rejected on retry.
307    assert_entries_all_ready(env, &entries)?;
308    let (generation, previous_split_ref, prev_gen) = match prev_split_idx {
309        Some(idx) => {
310            // Serialize a clone with previous_split_ref cleared: the stash is
311            // one-step by contract (rollback overwrites the nested ref with
312            // None and never follows it), so serializing the live ref would
313            // nest stash-in-stash tokens that grow ~(4/3)^n without bound.
314            let mut stash_copy = env.traffic_splits[idx].clone();
315            let prev_generation = stash_copy.generation;
316            stash_copy.previous_split_ref = None;
317            let snapshot = serde_json::to_value(&stash_copy).map_err(|e| {
318                TrafficSplitError::SnapshotEncode {
319                    detail: e.to_string(),
320                }
321            })?;
322            (
323                prev_generation + 1,
324                Some(inline_stash::stash_inline(snapshot)),
325                Some(prev_generation),
326            )
327        }
328        None => (0, None, None),
329    };
330    let split = TrafficSplit {
331        schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
332        env_id,
333        deployment_id,
334        bundle_id,
335        generation,
336        entries,
337        updated_at: now,
338        updated_by,
339        idempotency_key: idempotency_key.as_str().to_string(),
340        authorization_ref: authorization_ref
341            .map(PathBuf::from)
342            .unwrap_or_else(|| PathBuf::from("auth.json")),
343        previous_split_ref,
344    };
345    split.validate()?;
346    match prev_split_idx {
347        Some(idx) => env.traffic_splits[idx] = split.clone(),
348        None => env.traffic_splits.push(split.clone()),
349    }
350    Ok(TrafficSplitTransition {
351        split,
352        previous_generation: prev_gen,
353        new_generation: Some(generation),
354    })
355}
356
357/// Rollback the traffic split for a deployment to its one-step-previous
358/// snapshot. Bumps the generation and re-validates §5.3 admission on the
359/// restored entries (a historical split may route to revisions that have
360/// since been archived, failed, or removed).
361pub fn rollback_traffic_split(
362    env: &mut Environment,
363    deployment_id: DeploymentId,
364    now: DateTime<Utc>,
365) -> Result<TrafficRollbackTransition, TrafficSplitError> {
366    let idx = env
367        .traffic_splits
368        .iter()
369        .position(|s| s.deployment_id == deployment_id)
370        .ok_or_else(|| TrafficSplitError::NoSplit {
371            env_id: env.environment_id.clone(),
372            deployment_id,
373        })?;
374    let prev_split_generation = env.traffic_splits[idx].generation;
375    let prev_ref = env.traffic_splits[idx]
376        .previous_split_ref
377        .clone()
378        .ok_or(TrafficSplitError::NoPreviousSnapshot { deployment_id })?;
379    let prev_value = inline_stash::load_inline(&prev_ref)
380        .ok_or(TrafficSplitError::SnapshotMissing { prev_ref })?;
381    let mut restored: TrafficSplit =
382        serde_json::from_value(prev_value).map_err(|e| TrafficSplitError::SnapshotDecode {
383            detail: e.to_string(),
384        })?;
385    let new_generation = prev_split_generation + 1;
386    restored.generation = new_generation;
387    restored.previous_split_ref = None;
388    restored.updated_at = now;
389    restored.idempotency_key = format!("rollback-{}", env.traffic_splits[idx].idempotency_key);
390    restored.validate()?;
391    assert_entries_all_ready(env, &restored.entries)?;
392    env.traffic_splits[idx] = restored.clone();
393    Ok(TrafficRollbackTransition {
394        restored,
395        previous_generation: prev_split_generation,
396        new_generation,
397    })
398}
399
400/// §5.3 admission: every entry's revision must exist and be `Ready` before
401/// its split goes live. Used by both [`set_traffic_split`] and
402/// [`rollback_traffic_split`].
403pub fn assert_entries_all_ready(
404    env: &Environment,
405    entries: &[TrafficSplitEntry],
406) -> Result<(), TrafficSplitError> {
407    for entry in entries {
408        let rev = env
409            .revisions
410            .iter()
411            .find(|r| r.revision_id == entry.revision_id)
412            .ok_or_else(|| TrafficSplitError::AdmissionRevisionMissing {
413                env_id: env.environment_id.clone(),
414                revision_id: entry.revision_id,
415            })?;
416        if rev.lifecycle != RevisionLifecycle::Ready {
417            return Err(TrafficSplitError::NotReady {
418                revision_id: entry.revision_id,
419                lifecycle: rev.lifecycle,
420            });
421        }
422    }
423    Ok(())
424}
425
426/// Order-insensitive equality on basis-points-per-revision_id. Two payloads
427/// that route the same percentage to the same revision_id (in any
428/// permutation) collapse to "same" for idempotency purposes.
429pub fn entries_match(a: &[TrafficSplitEntry], b: &[TrafficSplitEntry]) -> bool {
430    if a.len() != b.len() {
431        return false;
432    }
433    let mut a_sorted: Vec<(&RevisionId, u32)> =
434        a.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
435    let mut b_sorted: Vec<(&RevisionId, u32)> =
436        b.iter().map(|e| (&e.revision_id, e.weight_bps)).collect();
437    a_sorted.sort_by_key(|(r, _)| r.to_string());
438    b_sorted.sort_by_key(|(r, _)| r.to_string());
439    a_sorted == b_sorted
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445    use crate::bundle_deployment::{
446        BundleDeployment, BundleDeploymentStatus, RevenueShareEntry, RouteBinding, TenantSelector,
447    };
448    use crate::engine::{StageRevisionPayload, stage_revision};
449    use crate::ids::{BundleId, CustomerId, PackId, PartyId};
450    use crate::revision::PackListEntry;
451    use crate::version::SemVer;
452    use chrono::TimeZone;
453    use serde_json::json;
454    use std::collections::BTreeMap;
455
456    fn env_id() -> EnvId {
457        EnvId::try_from("local").unwrap()
458    }
459
460    fn fixed_now() -> DateTime<Utc> {
461        Utc.with_ymd_and_hms(2026, 6, 12, 12, 0, 0).unwrap()
462    }
463
464    fn idem(raw: &str) -> IdempotencyKey {
465        IdempotencyKey::new(raw).expect("valid idempotency key")
466    }
467
468    fn deployment(deployment_id: DeploymentId) -> BundleDeployment {
469        BundleDeployment {
470            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
471            deployment_id,
472            env_id: env_id(),
473            bundle_id: BundleId::new("fast2flow"),
474            customer_id: CustomerId::new("local-dev"),
475            status: BundleDeploymentStatus::Active,
476            current_revisions: Vec::new(),
477            route_binding: RouteBinding {
478                hosts: vec!["fast2flow.local".to_string()],
479                path_prefixes: Vec::new(),
480                tenant_selector: TenantSelector {
481                    tenant: "default".to_string(),
482                    team: "default".to_string(),
483                },
484            },
485            revenue_share: vec![RevenueShareEntry {
486                party_id: PartyId::new("greentic"),
487                basis_points: 10_000,
488            }],
489            revenue_policy_ref: PathBuf::from("revenue.json"),
490            usage: None,
491            created_at: fixed_now(),
492            authorization_ref: PathBuf::from("auth.json"),
493            config_overrides: BTreeMap::new(),
494        }
495    }
496
497    fn stage_payload(deployment_id: DeploymentId) -> StageRevisionPayload {
498        StageRevisionPayload {
499            revision_id: RevisionId::new(),
500            deployment_id,
501            bundle_digest: "sha256:00".to_string(),
502            bundle_source_uri: None,
503            pack_list: vec![PackListEntry {
504                pack_id: PackId::new("greentic.test.pack"),
505                version: SemVer::new(1, 0, 0),
506                digest: "sha256:00".to_string(),
507                source_uri: None,
508            }],
509            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
510            pack_config_refs: Vec::new(),
511            config_digest: "sha256:00".to_string(),
512            signature_sidecar_ref: PathBuf::from("rev.sig"),
513            drain_seconds: 30,
514        }
515    }
516
517    /// Env with one deployment and two `Ready` revisions under it.
518    fn env_with_ready_revisions(
519        deployment_id: DeploymentId,
520    ) -> (Environment, RevisionId, RevisionId) {
521        let mut env = super::super::fresh_environment(
522            &env_id(),
523            "local".to_string(),
524            crate::environment::EnvironmentHostConfig {
525                env_id: env_id(),
526                region: None,
527                tenant_org_id: None,
528                listen_addr: None,
529                public_base_url: None,
530                gui_enabled: None,
531            },
532            Default::default(),
533            Default::default(),
534            Default::default(),
535        );
536        env.bundles.push(deployment(deployment_id));
537        let r1 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
538            .unwrap()
539            .revision_id;
540        let r2 = stage_revision(&mut env, stage_payload(deployment_id), fixed_now())
541            .unwrap()
542            .revision_id;
543        for rev in &mut env.revisions {
544            rev.lifecycle = RevisionLifecycle::Ready;
545        }
546        (env, r1, r2)
547    }
548
549    fn set_payload(
550        deployment_id: DeploymentId,
551        entries: Vec<TrafficSplitEntry>,
552    ) -> SetTrafficSplitPayload {
553        SetTrafficSplitPayload {
554            deployment_id,
555            entries,
556            updated_by: "operator@local".to_string(),
557            authorization_ref: None,
558        }
559    }
560
561    fn full_weight(revision_id: RevisionId) -> Vec<TrafficSplitEntry> {
562        vec![TrafficSplitEntry {
563            revision_id,
564            weight_bps: 10_000,
565        }]
566    }
567
568    // --- set: happy path + generation chain ---
569
570    #[test]
571    fn set_first_split_starts_at_generation_zero_without_stash() {
572        let deployment_id = DeploymentId::new();
573        let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
574
575        let t = set_traffic_split(
576            &mut env,
577            set_payload(deployment_id, full_weight(r1)),
578            &idem("k1"),
579            fixed_now(),
580        )
581        .unwrap();
582        assert!(t.mutated());
583        assert_eq!(t.split.generation, 0);
584        assert_eq!(t.previous_generation, None);
585        assert_eq!(t.new_generation, Some(0));
586        assert_eq!(t.split.previous_split_ref, None);
587        assert_eq!(t.split.idempotency_key, "k1");
588        assert_eq!(
589            t.split.authorization_ref,
590            PathBuf::from("auth.json"),
591            "absent authorization_ref defaults"
592        );
593        assert_eq!(env.traffic_splits.len(), 1);
594    }
595
596    #[test]
597    fn set_second_split_advances_generation_and_stashes_previous() {
598        let deployment_id = DeploymentId::new();
599        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
600
601        set_traffic_split(
602            &mut env,
603            set_payload(deployment_id, full_weight(r1)),
604            &idem("k1"),
605            fixed_now(),
606        )
607        .unwrap();
608        let t = set_traffic_split(
609            &mut env,
610            set_payload(deployment_id, full_weight(r2)),
611            &idem("k2"),
612            fixed_now(),
613        )
614        .unwrap();
615        assert_eq!(t.previous_generation, Some(0));
616        assert_eq!(t.new_generation, Some(1));
617        let stash = t.split.previous_split_ref.as_ref().expect("stash present");
618        let prev: TrafficSplit =
619            serde_json::from_value(inline_stash::load_inline(stash).expect("stash decodes"))
620                .expect("stash is a TrafficSplit");
621        assert_eq!(prev.generation, 0);
622        assert_eq!(prev.entries, full_weight(r1));
623        assert_eq!(env.traffic_splits.len(), 1, "replace, not append");
624    }
625
626    // --- set: idempotency contract ---
627
628    #[test]
629    fn set_same_key_same_entries_is_a_no_op_replay() {
630        let deployment_id = DeploymentId::new();
631        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
632
633        let entries = vec![
634            TrafficSplitEntry {
635                revision_id: r1,
636                weight_bps: 4_000,
637            },
638            TrafficSplitEntry {
639                revision_id: r2,
640                weight_bps: 6_000,
641            },
642        ];
643        set_traffic_split(
644            &mut env,
645            set_payload(deployment_id, entries.clone()),
646            &idem("k1"),
647            fixed_now(),
648        )
649        .unwrap();
650        let snapshot = env.clone();
651
652        // Replay with the same key and the same entries PERMUTED — the
653        // match is order-insensitive.
654        let permuted: Vec<TrafficSplitEntry> = entries.into_iter().rev().collect();
655        let t = set_traffic_split(
656            &mut env,
657            set_payload(deployment_id, permuted),
658            &idem("k1"),
659            fixed_now(),
660        )
661        .unwrap();
662        assert!(!t.mutated());
663        assert_eq!(t.previous_generation, None);
664        assert_eq!(t.new_generation, None);
665        assert_eq!(t.split.generation, 0, "replay returns the live split");
666        assert_eq!(env, snapshot, "no-op replay must not touch the env");
667    }
668
669    #[test]
670    fn set_same_key_different_entries_is_a_key_reuse_conflict() {
671        let deployment_id = DeploymentId::new();
672        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
673
674        set_traffic_split(
675            &mut env,
676            set_payload(deployment_id, full_weight(r1)),
677            &idem("k1"),
678            fixed_now(),
679        )
680        .unwrap();
681        let err = set_traffic_split(
682            &mut env,
683            set_payload(deployment_id, full_weight(r2)),
684            &idem("k1"),
685            fixed_now(),
686        )
687        .unwrap_err();
688        assert!(
689            matches!(&err, TrafficSplitError::IdempotencyKeyReused { key, .. } if key == "k1"),
690            "unexpected error: {err:?}"
691        );
692    }
693
694    // --- set: referential + admission guards ---
695
696    #[test]
697    fn set_rejects_unknown_deployment_and_unknown_revision() {
698        let deployment_id = DeploymentId::new();
699        let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
700
701        let err = set_traffic_split(
702            &mut env,
703            set_payload(DeploymentId::new(), full_weight(r1)),
704            &idem("k1"),
705            fixed_now(),
706        )
707        .unwrap_err();
708        assert!(matches!(err, TrafficSplitError::DeploymentNotFound { .. }));
709
710        let err = set_traffic_split(
711            &mut env,
712            set_payload(deployment_id, full_weight(RevisionId::new())),
713            &idem("k1"),
714            fixed_now(),
715        )
716        .unwrap_err();
717        assert!(matches!(err, TrafficSplitError::RevisionNotFound { .. }));
718        assert!(env.traffic_splits.is_empty());
719    }
720
721    #[test]
722    fn set_rejects_revision_owned_by_another_deployment() {
723        let deployment_id = DeploymentId::new();
724        let (mut env, _, _) = env_with_ready_revisions(deployment_id);
725        let other_deployment = DeploymentId::new();
726        env.bundles.push(deployment(other_deployment));
727        let foreign = stage_revision(&mut env, stage_payload(other_deployment), fixed_now())
728            .unwrap()
729            .revision_id;
730        env.revisions.last_mut().unwrap().lifecycle = RevisionLifecycle::Ready;
731
732        let err = set_traffic_split(
733            &mut env,
734            set_payload(deployment_id, full_weight(foreign)),
735            &idem("k1"),
736            fixed_now(),
737        )
738        .unwrap_err();
739        assert!(
740            matches!(
741                &err,
742                TrafficSplitError::WrongDeployment { revision_id, actual, expected }
743                    if *revision_id == foreign
744                        && *actual == other_deployment
745                        && *expected == deployment_id
746            ),
747            "unexpected error: {err:?}"
748        );
749    }
750
751    #[test]
752    fn set_rejects_non_ready_revision_per_admission_gate() {
753        let deployment_id = DeploymentId::new();
754        let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
755        env.revisions[0].lifecycle = RevisionLifecycle::Staged;
756
757        let err = set_traffic_split(
758            &mut env,
759            set_payload(deployment_id, full_weight(r1)),
760            &idem("k1"),
761            fixed_now(),
762        )
763        .unwrap_err();
764        assert!(
765            matches!(
766                &err,
767                TrafficSplitError::NotReady { lifecycle, .. }
768                    if *lifecycle == RevisionLifecycle::Staged
769            ),
770            "unexpected error: {err:?}"
771        );
772    }
773
774    #[test]
775    fn set_rejects_weights_not_summing_to_ten_thousand() {
776        let deployment_id = DeploymentId::new();
777        let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
778
779        let err = set_traffic_split(
780            &mut env,
781            set_payload(
782                deployment_id,
783                vec![TrafficSplitEntry {
784                    revision_id: r1,
785                    weight_bps: 9_999,
786                }],
787            ),
788            &idem("k1"),
789            fixed_now(),
790        )
791        .unwrap_err();
792        assert!(
793            matches!(err, TrafficSplitError::Invalid(_)),
794            "unexpected error: {err:?}"
795        );
796        assert!(env.traffic_splits.is_empty(), "invalid split not applied");
797    }
798
799    // --- rollback ---
800
801    #[test]
802    fn rollback_restores_previous_entries_and_advances_generation() {
803        let deployment_id = DeploymentId::new();
804        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
805
806        set_traffic_split(
807            &mut env,
808            set_payload(deployment_id, full_weight(r1)),
809            &idem("k1"),
810            fixed_now(),
811        )
812        .unwrap();
813        set_traffic_split(
814            &mut env,
815            set_payload(deployment_id, full_weight(r2)),
816            &idem("k2"),
817            fixed_now(),
818        )
819        .unwrap();
820
821        let t = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap();
822        assert_eq!(t.previous_generation, 1);
823        assert_eq!(t.new_generation, 2, "rollback advances, never rewinds");
824        assert_eq!(t.restored.entries, full_weight(r1));
825        assert_eq!(t.restored.idempotency_key, "rollback-k2");
826        assert_eq!(
827            t.restored.previous_split_ref, None,
828            "one-step rollback only"
829        );
830        assert_eq!(env.traffic_splits[0], t.restored);
831
832        // The restored split has no further snapshot — a second rollback
833        // is a conflict, not a flip-flop.
834        let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
835        assert!(matches!(err, TrafficSplitError::NoPreviousSnapshot { .. }));
836    }
837
838    #[test]
839    fn rollback_without_split_is_no_split() {
840        let deployment_id = DeploymentId::new();
841        let (mut env, _, _) = env_with_ready_revisions(deployment_id);
842        let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
843        assert!(matches!(err, TrafficSplitError::NoSplit { .. }));
844    }
845
846    #[test]
847    fn rollback_re_checks_admission_on_restored_entries() {
848        let deployment_id = DeploymentId::new();
849        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
850
851        set_traffic_split(
852            &mut env,
853            set_payload(deployment_id, full_weight(r1)),
854            &idem("k1"),
855            fixed_now(),
856        )
857        .unwrap();
858        set_traffic_split(
859            &mut env,
860            set_payload(deployment_id, full_weight(r2)),
861            &idem("k2"),
862            fixed_now(),
863        )
864        .unwrap();
865        // The historical target drained/archived since the stash was taken.
866        env.revisions[0].lifecycle = RevisionLifecycle::Archived;
867        let snapshot = env.clone();
868
869        let err = rollback_traffic_split(&mut env, deployment_id, fixed_now()).unwrap_err();
870        assert!(
871            matches!(&err, TrafficSplitError::NotReady { revision_id, .. } if *revision_id == r1),
872            "unexpected error: {err:?}"
873        );
874        assert_eq!(env, snapshot, "failed rollback must not mutate the env");
875    }
876
877    // --- helpers ---
878
879    #[test]
880    fn entries_match_is_order_insensitive_and_weight_sensitive() {
881        let (r1, r2) = (RevisionId::new(), RevisionId::new());
882        let a = vec![
883            TrafficSplitEntry {
884                revision_id: r1,
885                weight_bps: 4_000,
886            },
887            TrafficSplitEntry {
888                revision_id: r2,
889                weight_bps: 6_000,
890            },
891        ];
892        let permuted: Vec<TrafficSplitEntry> = a.iter().rev().cloned().collect();
893        assert!(entries_match(&a, &permuted));
894        let reweighted = vec![
895            TrafficSplitEntry {
896                revision_id: r1,
897                weight_bps: 5_000,
898            },
899            TrafficSplitEntry {
900                revision_id: r2,
901                weight_bps: 5_000,
902            },
903        ];
904        assert!(!entries_match(&a, &reweighted));
905        assert!(!entries_match(&a, &a[..1]));
906    }
907
908    // --- wire-format pinning (must match the PR-3b client encoding) ---
909
910    #[test]
911    fn set_payload_wire_format_is_pinned() {
912        let deployment_id = DeploymentId::new();
913        let revision_id = RevisionId::new();
914        let payload = SetTrafficSplitPayload {
915            deployment_id,
916            entries: vec![TrafficSplitEntry {
917                revision_id,
918                weight_bps: 10_000,
919            }],
920            updated_by: "operator@local".to_string(),
921            authorization_ref: None,
922        };
923        assert_eq!(
924            serde_json::to_value(&payload).unwrap(),
925            json!({
926                "deployment_id": deployment_id.to_string(),
927                "entries": [{"revision_id": revision_id.to_string(), "weight_bps": 10_000}],
928                "updated_by": "operator@local",
929            }),
930            "authorization_ref must be ABSENT when None (client encoding)"
931        );
932
933        let rollback = RollbackTrafficSplitPayload { deployment_id };
934        assert_eq!(
935            serde_json::to_value(&rollback).unwrap(),
936            json!({"deployment_id": deployment_id.to_string()})
937        );
938    }
939
940    #[test]
941    fn apply_outcome_round_trips_with_environment_snapshot() {
942        let deployment_id = DeploymentId::new();
943        let (mut env, r1, _) = env_with_ready_revisions(deployment_id);
944        let t = set_traffic_split(
945            &mut env,
946            set_payload(deployment_id, full_weight(r1)),
947            &idem("k1"),
948            fixed_now(),
949        )
950        .unwrap();
951        let outcome = ApplyTrafficSplitOutcome {
952            split: t.split,
953            previous_generation: t.previous_generation,
954            new_generation: t.new_generation,
955            environment: env,
956        };
957        let value = serde_json::to_value(&outcome).unwrap();
958        assert_eq!(value["new_generation"], 0);
959        assert_eq!(value["previous_generation"], serde_json::Value::Null);
960        assert_eq!(value["environment"]["environment_id"], "local");
961        let back: ApplyTrafficSplitOutcome = serde_json::from_value(value).unwrap();
962        assert_eq!(back.split, outcome.split);
963        assert_eq!(back.environment, outcome.environment);
964    }
965
966    #[test]
967    fn stash_is_bounded_to_one_level_across_multiple_sets() {
968        let deployment_id = DeploymentId::new();
969        let (mut env, r1, r2) = env_with_ready_revisions(deployment_id);
970
971        // Three successive sets with distinct keys, alternating revisions.
972        set_traffic_split(
973            &mut env,
974            set_payload(deployment_id, full_weight(r1)),
975            &idem("k1"),
976            fixed_now(),
977        )
978        .unwrap();
979        let t2 = set_traffic_split(
980            &mut env,
981            set_payload(deployment_id, full_weight(r2)),
982            &idem("k2"),
983            fixed_now(),
984        )
985        .unwrap();
986        let t3 = set_traffic_split(
987            &mut env,
988            set_payload(deployment_id, full_weight(r1)),
989            &idem("k3"),
990            fixed_now(),
991        )
992        .unwrap();
993
994        // The live split's stash decodes into a TrafficSplit whose own
995        // previous_split_ref is None — exactly one level, no nested token.
996        let stash3 = t3
997            .split
998            .previous_split_ref
999            .as_ref()
1000            .expect("stash present after set #3");
1001        let prev3: TrafficSplit =
1002            serde_json::from_value(inline_stash::load_inline(stash3).expect("stash decodes"))
1003                .expect("stash is a TrafficSplit");
1004        assert_eq!(
1005            prev3.previous_split_ref, None,
1006            "stash must be one level deep"
1007        );
1008        assert_eq!(prev3.generation, 1);
1009        assert_eq!(prev3.entries, full_weight(r2));
1010
1011        // Same check on set #2's stash.
1012        let stash2 = t2
1013            .split
1014            .previous_split_ref
1015            .as_ref()
1016            .expect("stash present after set #2");
1017        let prev2: TrafficSplit =
1018            serde_json::from_value(inline_stash::load_inline(stash2).expect("stash decodes"))
1019                .expect("stash is a TrafficSplit");
1020        assert_eq!(
1021            prev2.previous_split_ref, None,
1022            "stash must be one level deep"
1023        );
1024
1025        // Token length after set #3 is in the same ballpark as set #2 —
1026        // proves boundedness (without the fix, #3 would embed #2's token).
1027        let len3 = stash3.as_os_str().len();
1028        let len2 = stash2.as_os_str().len();
1029        assert!(
1030            len3 <= len2 + 64,
1031            "stash tokens must not grow unboundedly: len(#3)={len3} vs len(#2)={len2}",
1032        );
1033    }
1034}