Skip to main content

greentic_deployer/environment/
lifecycle.rs

1//! Revision-lifecycle storage guard (A5 of `plans/next-gen-deployment.md`).
2//!
3//! Wraps the pure [`greentic_deploy_spec::is_valid_transition`] predicate
4//! from `greentic-deploy-spec` with the storage-side semantics that
5//! operator commands and B-phase orchestrators need:
6//!
7//! - load the env from a [`Locked<'_>`](crate::environment::Locked) transaction,
8//! - find the revision by id (typed `NotFound`),
9//! - walk an `accepted_chain` of `(from, to)` edges, advancing the revision
10//!   through every legal hop until it lands in the final state,
11//! - report a typed `InvalidTransition` for any edge the spec rejects, and a
12//!   typed `Conflict` for a revision that started outside the chain,
13//! - optionally prune the revision from every traffic split and from each
14//!   matching `BundleDeployment.current_revisions` (the archive path),
15//! - save the env back through the same `Locked<'_>` handle so the per-env
16//!   flock spans the whole load → mutate → save critical section.
17//!
18//! `cli::revisions::{stage, warm, drain, archive}` delegate the inner body
19//! of their `transact` closures to [`apply_revision_transition`], and the
20//! same helper is the entrypoint future B-phase consumers (gtc start
21//! orchestration #221, B9 warm/ready gate, A7 audit emission) call when
22//! they need to drive a revision through the matrix.
23//!
24//! The helper does **not** mint revisions — `stage` constructs the
25//! `Revision` struct itself and pushes it onto `Environment.revisions`. The
26//! lifecycle guard only owns transitions between *existing* revisions.
27
28use greentic_deploy_spec::engine::RevisionLifecycleError;
29use greentic_deploy_spec::{Environment, Revision, RevisionId, RevisionLifecycle};
30use thiserror::Error;
31
32use crate::environment::{Locked, StoreError};
33
34// PR-4.2b: the pure types (and the chain-walk core below) moved to
35// `greentic_deploy_spec::engine` so the operator-store-server applies the
36// same lifecycle semantics as `LocalFsStore`. Re-exported here so every
37// existing `environment::lifecycle::…` path keeps working.
38pub use greentic_deploy_spec::engine::{ActiveSplitRef, HealthCheckId, HealthGateFailure};
39
40/// Errors produced by [`apply_revision_transition`]. Cleanly maps onto
41/// `cli::OpError` via the `From` impl in `cli/mod.rs`. The storage-free
42/// variants mirror [`RevisionLifecycleError`] 1:1 (see the `From` impl
43/// below); [`LifecycleError::Store`] is the local-storage addition.
44#[derive(Debug, Error)]
45pub enum LifecycleError {
46    /// The targeted revision was not present in the loaded environment.
47    #[error("revision `{revision_id}` not found in env `{env_id}`")]
48    NotFound {
49        env_id: greentic_deploy_spec::EnvId,
50        revision_id: RevisionId,
51    },
52    /// The spec matrix rejected an edge inside the requested chain. This is
53    /// a programming error in the caller, not a runtime conflict — the
54    /// helper should never see this in production because every chain
55    /// passed in from `cli::revisions` is hand-curated. Surfaced as a
56    /// distinct variant so call sites can choose to panic in debug.
57    #[error("spec rejects transition `{from:?} → {to:?}`")]
58    InvalidTransition {
59        from: RevisionLifecycle,
60        to: RevisionLifecycle,
61    },
62    /// The revision was loaded in a state that does not start any edge in
63    /// the accepted chain. `actual` carries the lifecycle the helper found;
64    /// `expected_starts` lists the `from` states of each accepted edge so
65    /// callers can render a useful error.
66    #[error(
67        "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
68    )]
69    Conflict {
70        revision_id: RevisionId,
71        actual: RevisionLifecycle,
72        expected_starts: Vec<RevisionLifecycle>,
73    },
74    /// The caller passed an empty `accepted_chain`. Internal-API misuse.
75    #[error("transition chain is empty; cannot apply any state change")]
76    EmptyChain,
77    /// The archive path (`prune_from_splits = true`) was invoked against a
78    /// revision still referenced by one or more live traffic splits.
79    /// Blindly pruning the entry would either silently drop the route
80    /// (100%-single-entry split) or produce a split whose weights no
81    /// longer sum to 10,000 bps (the spec invariant), so the helper
82    /// refuses. Callers must rebalance traffic via `gtc op traffic set`
83    /// before retrying. `splits` carries the offending references for
84    /// rendering an actionable error.
85    #[error(
86        "revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
87    )]
88    ActiveTrafficReference {
89        revision_id: RevisionId,
90        splits: Vec<ActiveSplitRef>,
91    },
92    /// The warm/ready health gate rejected the revision after the chain
93    /// reached its final state. The helper has flipped the revision's
94    /// lifecycle to `Failed` and persisted the env before surfacing this
95    /// error, so the on-disk state reflects the failed warm — callers
96    /// then choose to retry (`Failed → Staged`) or retire
97    /// (`Failed → Archived`) via the operator CLI.
98    #[error(
99        "revision `{revision_id}` failed health gate ({} check(s) failed): {message}",
100        failed_checks.len()
101    )]
102    HealthGateFailed {
103        revision_id: RevisionId,
104        failed_checks: Vec<HealthCheckId>,
105        message: String,
106    },
107    /// Underlying storage layer failure (load/save through the `Locked<'_>`).
108    #[error(transparent)]
109    Store(#[from] StoreError),
110}
111
112/// Lift a pure-engine lifecycle failure onto the local storage-aware error.
113/// Variants map 1:1; the engine's stage-verb `DeploymentNotFound` (which has
114/// no lifecycle twin here) surfaces through the storage variant the CLI
115/// already maps to a dependent-not-found.
116impl From<RevisionLifecycleError> for LifecycleError {
117    fn from(err: RevisionLifecycleError) -> Self {
118        match err {
119            RevisionLifecycleError::NotFound {
120                env_id,
121                revision_id,
122            } => Self::NotFound {
123                env_id,
124                revision_id,
125            },
126            RevisionLifecycleError::InvalidTransition { from, to } => {
127                Self::InvalidTransition { from, to }
128            }
129            RevisionLifecycleError::Conflict {
130                revision_id,
131                actual,
132                expected_starts,
133            } => Self::Conflict {
134                revision_id,
135                actual,
136                expected_starts,
137            },
138            RevisionLifecycleError::EmptyChain => Self::EmptyChain,
139            RevisionLifecycleError::ActiveTrafficReference {
140                revision_id,
141                splits,
142            } => Self::ActiveTrafficReference {
143                revision_id,
144                splits,
145            },
146            RevisionLifecycleError::HealthGateFailed {
147                revision_id,
148                failed_checks,
149                message,
150            } => Self::HealthGateFailed {
151                revision_id,
152                failed_checks,
153                message,
154            },
155            err @ RevisionLifecycleError::DuplicateRevision { .. } => {
156                Self::Store(StoreError::Conflict(err.to_string()))
157            }
158            err @ RevisionLifecycleError::DeploymentNotFound { .. } => {
159                Self::Store(StoreError::DependentNotFound(err.to_string()))
160            }
161        }
162    }
163}
164
165/// Apply a revision lifecycle transition under an already-held env lock.
166///
167/// `accepted_chain` is a list of `(from, to)` edges, applied in order: the
168/// helper finds the revision, then for each edge whose `from` matches the
169/// revision's current lifecycle, it validates the edge against the spec
170/// matrix and advances the revision. The chain terminates when no further
171/// edge's `from` matches the (now mutated) revision, and the helper
172/// asserts the revision has reached the final edge's `to` state.
173///
174/// **Idempotent on the final state:** if the revision starts already in
175/// the chain's final state, the helper succeeds without raising
176/// Conflict. `on_final` still runs (e.g. re-stamps `warmed_at`) and the
177/// env is still saved through the lock; callers needing strict
178/// once-only semantics must check the loaded state themselves. Conflict
179/// surfaces only when the loaded state is neither one of the chain's
180/// `from` states nor the chain's final `to` state.
181///
182/// `on_final` runs once after the last advance, on the freshly-mutated
183/// [`Revision`] reference, before the env is saved. Use it to stamp
184/// timestamps like `warmed_at`. The helper expects `FnOnce` because each
185/// transition is a one-shot.
186///
187/// `prune_from_splits = true` is the archive-path knob. Before pruning,
188/// the helper scans every `TrafficSplit.entries` for references to the
189/// archived revision: if any are found, it refuses with
190/// [`LifecycleError::ActiveTrafficReference`] (no save, no mutation) so
191/// the operator can rebalance traffic through `gtc op traffic set`
192/// first. If no live references exist, the revision is removed from each
193/// [`BundleDeployment::current_revisions`](greentic_deploy_spec::BundleDeployment::current_revisions)
194/// whose `deployment_id` matches the archived revision's (a tracking
195/// field, not a routing-impact one). Empty traffic splits are not
196/// possible at this point because the guard would have caught them; the
197/// invariant is preserved across the save.
198///
199/// The helper saves the env through `locked.save(...)` before returning,
200/// so the entire read-modify-write completes inside the caller's
201/// `LocalFsStore::transact` lock scope. On any error, no save is performed
202/// and the on-disk env remains untouched.
203///
204/// Returns the post-transition [`Revision`] (cloned out of the saved env)
205/// so callers can render summaries or emit audit events without re-loading.
206pub fn apply_revision_transition<F>(
207    locked: &Locked<'_>,
208    revision_id: RevisionId,
209    accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
210    on_final: F,
211    prune_from_splits: bool,
212) -> Result<Revision, LifecycleError>
213where
214    F: FnOnce(&mut Revision),
215{
216    apply_revision_transition_with_health_gate(
217        locked,
218        revision_id,
219        accepted_chain,
220        on_final,
221        prune_from_splits,
222        |_env, _revision| Ok(()),
223    )
224}
225
226/// Gate-aware variant of [`apply_revision_transition`] for the B9 warm/ready
227/// health gate.
228///
229/// Behaves exactly like the gate-less helper through chain advance and the
230/// `prune_from_splits` active-traffic guard. The gate fires **only when an
231/// edge in `accepted_chain` actually advanced the lifecycle** — an idempotent
232/// retry against an already-final-state revision skips the gate entirely
233/// (the revision is already committed at its target state, often with live
234/// traffic routing to it; rerunning a transient gate would demote a healthy
235/// live revision to `Failed` while the runtime-config materializer keeps
236/// routing traffic to it). `on_final` still runs on the no-op-walk path per
237/// the existing idempotent-retry contract — `warmed_at` is re-stamped, but
238/// the lifecycle does not change.
239///
240/// When the chain advanced, `health_gate` runs against the freshly-mutated
241/// `(env, revision)` view. The gate sees the **post-chain** revision (e.g.
242/// `Ready`) so checks can branch on the would-be-committed state.
243///
244/// **On gate failure** (`Err(HealthGateFailure)`, chain-advanced path only):
245/// - the revision's lifecycle is flipped to
246///   [`RevisionLifecycle::Failed`] (the spec matrix allows
247///   `Staged|Warming|Ready|Inactive → Failed`),
248/// - `on_final` is **not** run (so `warmed_at` is not stamped on a failed warm),
249/// - the prune mutation is **not** applied (only relevant when
250///   `prune_from_splits = true`, which is archive-only),
251/// - the env is saved through `locked.save(...)` so the on-disk state
252///   reflects the failed warm,
253/// - the helper returns [`LifecycleError::HealthGateFailed`].
254///
255/// If the spec matrix refuses `current → Failed` (no legal warm-chain final
256/// state today triggers this, but defensive for future callers), the helper
257/// surfaces [`LifecycleError::InvalidTransition`] without persisting — the
258/// caller passed a chain that cannot fail to `Failed`.
259///
260/// On gate success, the helper runs `on_final`, prunes if armed, saves, and
261/// returns the revision — identical to the gate-less path.
262pub fn apply_revision_transition_with_health_gate<F, G>(
263    locked: &Locked<'_>,
264    revision_id: RevisionId,
265    accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
266    on_final: F,
267    prune_from_splits: bool,
268    health_gate: G,
269) -> Result<Revision, LifecycleError>
270where
271    F: FnOnce(&mut Revision),
272    G: FnOnce(&Environment, &Revision) -> Result<(), HealthGateFailure>,
273{
274    // The pure chain-walk core moved to `greentic_deploy_spec::engine` in
275    // PR-4.2b (the operator-store-server drives the same function). This
276    // wrapper owns the storage halves: load before, save per the engine's
277    // persist rule — `Ok` and `env_mutated` errors (the gate-failed flip to
278    // `Failed`) persist; every other error discards the in-memory env.
279    let mut env = locked.load()?;
280    match greentic_deploy_spec::engine::walk_revision_chain(
281        &mut env,
282        revision_id,
283        accepted_chain,
284        None,
285        on_final,
286        prune_from_splits,
287        health_gate,
288    ) {
289        Ok(transition) => {
290            locked.save(&env)?;
291            Ok(transition.revision)
292        }
293        Err(err) if err.env_mutated() => {
294            locked.save(&env)?;
295            Err(err.into())
296        }
297        Err(err) => Err(err.into()),
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304    use crate::environment::{EnvironmentStore, LocalFsStore};
305    use chrono::{TimeZone, Utc};
306    use greentic_deploy_spec::{
307        BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
308        Environment, EnvironmentHostConfig, PackId, PackListEntry, PartyId, RevenueShareEntry,
309        Revision, RevisionId, RevisionLifecycle, RouteBinding, SchemaVersion, SemVer,
310        TenantSelector, TrafficSplit, TrafficSplitEntry,
311    };
312    use std::collections::BTreeMap;
313    use std::path::PathBuf;
314    use tempfile::tempdir;
315
316    const ENV_ID: &str = "local";
317
318    fn env_id() -> EnvId {
319        EnvId::try_from(ENV_ID).unwrap()
320    }
321
322    fn fixed_now() -> chrono::DateTime<Utc> {
323        Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap()
324    }
325
326    fn make_env() -> Environment {
327        Environment {
328            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
329            environment_id: env_id(),
330            name: ENV_ID.to_string(),
331            host_config: EnvironmentHostConfig {
332                env_id: env_id(),
333                region: None,
334                tenant_org_id: None,
335                listen_addr: None,
336                public_base_url: None,
337                gui_enabled: None,
338            },
339            packs: Vec::new(),
340            credentials_ref: None,
341            bundles: Vec::new(),
342            revisions: Vec::new(),
343            traffic_splits: Vec::new(),
344            messaging_endpoints: Vec::new(),
345            extensions: Vec::new(),
346            revocation: Default::default(),
347            retention: Default::default(),
348            health: Default::default(),
349        }
350    }
351
352    fn make_revision(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
353        Revision {
354            schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
355            revision_id: RevisionId::new(),
356            env_id: env_id(),
357            bundle_id: BundleId::new("fast2flow"),
358            deployment_id,
359            sequence: 1,
360            created_at: fixed_now(),
361            bundle_digest: "sha256:00".to_string(),
362            bundle_source_uri: None,
363            pack_list: vec![PackListEntry {
364                pack_id: PackId::new("greentic.test.pack"),
365                version: SemVer::new(1, 0, 0),
366                digest: "sha256:00".to_string(),
367                source_uri: None,
368            }],
369            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
370            pack_config_refs: Vec::new(),
371            config_digest: "sha256:00".to_string(),
372            signature_sidecar_ref: PathBuf::from("rev.sig"),
373            lifecycle,
374            staged_at: None,
375            warmed_at: None,
376            drain_seconds: 30,
377            abort_metrics: Vec::new(),
378        }
379    }
380
381    fn make_bundle_deployment() -> BundleDeployment {
382        BundleDeployment {
383            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
384            deployment_id: DeploymentId::new(),
385            env_id: env_id(),
386            bundle_id: BundleId::new("fast2flow"),
387            customer_id: CustomerId::new("local-dev"),
388            status: BundleDeploymentStatus::Active,
389            current_revisions: Vec::new(),
390            route_binding: RouteBinding {
391                hosts: vec!["fast2flow.local".to_string()],
392                path_prefixes: Vec::new(),
393                tenant_selector: TenantSelector {
394                    tenant: "default".to_string(),
395                    team: "default".to_string(),
396                },
397            },
398            revenue_share: vec![RevenueShareEntry {
399                party_id: PartyId::new("greentic"),
400                basis_points: 10_000,
401            }],
402            revenue_policy_ref: PathBuf::from("revenue.json"),
403            usage: None,
404            created_at: fixed_now(),
405            authorization_ref: PathBuf::from("auth.json"),
406            config_overrides: BTreeMap::new(),
407        }
408    }
409
410    /// Build an env with one bundle + one revision in the given lifecycle.
411    /// Returns `(store, env_id, revision_id)`.
412    fn seed_one_revision(lifecycle: RevisionLifecycle) -> (LocalFsStore, EnvId, RevisionId) {
413        let dir = tempdir().unwrap();
414        let store = LocalFsStore::new(dir.path().to_path_buf());
415        let mut env = make_env();
416        let bundle = make_bundle_deployment();
417        let did = bundle.deployment_id;
418        let revision = make_revision(did, lifecycle);
419        let rid = revision.revision_id;
420        env.bundles.push(bundle);
421        env.bundles[0].current_revisions.push(rid);
422        env.revisions.push(revision);
423        store.save(&env).unwrap();
424        // Leak the tempdir into the returned store so the dir survives test scope.
425        // (LocalFsStore holds its root by value; the tempdir guard is dropped at
426        // function return, but our root is already inside the tempdir's path.
427        // To survive, we extract the path and keep the dir alive via std::mem::forget.)
428        std::mem::forget(dir);
429        (store, env_id(), rid)
430    }
431
432    #[test]
433    fn applies_two_hop_chain_to_final_state() {
434        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
435        let revision = store
436            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
437                apply_revision_transition(
438                    locked,
439                    rid,
440                    &[
441                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
442                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
443                    ],
444                    |_| {},
445                    false,
446                )
447            })
448            .unwrap();
449        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
450
451        // Persisted on disk.
452        let env = store.load(&env_id).unwrap();
453        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
454    }
455
456    #[test]
457    fn on_final_runs_once_on_post_advance_revision() {
458        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
459        let revision = store
460            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
461                apply_revision_transition(
462                    locked,
463                    rid,
464                    &[
465                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
466                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
467                    ],
468                    |r| {
469                        r.warmed_at = Some(fixed_now());
470                    },
471                    false,
472                )
473            })
474            .unwrap();
475        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
476        assert_eq!(revision.warmed_at, Some(fixed_now()));
477    }
478
479    #[test]
480    fn missing_revision_surfaces_not_found_without_touching_env() {
481        let (store, env_id, _rid) = seed_one_revision(RevisionLifecycle::Staged);
482        let ghost = RevisionId::new();
483        let err = store
484            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
485                apply_revision_transition(
486                    locked,
487                    ghost,
488                    &[(RevisionLifecycle::Staged, RevisionLifecycle::Warming)],
489                    |_| {},
490                    false,
491                )
492            })
493            .unwrap_err();
494        match err {
495            LifecycleError::NotFound {
496                revision_id,
497                env_id: e,
498            } => {
499                assert_eq!(revision_id, ghost);
500                assert_eq!(e, env_id);
501            }
502            other => panic!("expected NotFound, got `{other:?}`"),
503        }
504
505        // Original revision still in `Staged` on disk.
506        let env = store.load(&env_id).unwrap();
507        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
508    }
509
510    #[test]
511    fn revision_outside_chain_surfaces_conflict() {
512        // Seed in `Draining`, request the warm chain `Staged → Warming → Ready`.
513        // `Draining` matches neither edge's `from` and isn't the chain's final
514        // state, so the helper must surface a Conflict without mutating state.
515        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
516        let err = store
517            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
518                apply_revision_transition(
519                    locked,
520                    rid,
521                    &[
522                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
523                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
524                    ],
525                    |_| {},
526                    false,
527                )
528            })
529            .unwrap_err();
530        match err {
531            LifecycleError::Conflict {
532                revision_id,
533                actual,
534                expected_starts,
535            } => {
536                assert_eq!(revision_id, rid);
537                assert_eq!(actual, RevisionLifecycle::Draining);
538                assert_eq!(
539                    expected_starts,
540                    vec![RevisionLifecycle::Staged, RevisionLifecycle::Warming]
541                );
542            }
543            other => panic!("expected Conflict, got `{other:?}`"),
544        }
545
546        // No save happened — env still has the revision in its original state.
547        let env = store.load(&env_id).unwrap();
548        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
549    }
550
551    #[test]
552    fn already_in_final_state_is_idempotent_success() {
553        // Seed in `Ready`, request the warm chain `Staged → Warming → Ready`.
554        // No edge applies, but the revision is already at the final state →
555        // helper returns Ok (idempotent retry semantics).
556        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
557        let revision = store
558            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
559                apply_revision_transition(
560                    locked,
561                    rid,
562                    &[
563                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
564                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
565                    ],
566                    |_| {},
567                    false,
568                )
569            })
570            .unwrap();
571        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
572    }
573
574    #[test]
575    fn empty_chain_returns_empty_chain_error() {
576        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
577        let err = store
578            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
579                apply_revision_transition(locked, rid, &[], |_| {}, false)
580            })
581            .unwrap_err();
582        assert!(matches!(err, LifecycleError::EmptyChain));
583    }
584
585    #[test]
586    fn archive_succeeds_and_prunes_current_revisions_when_no_live_traffic() {
587        // Happy path: revision is not in any traffic split (or no splits
588        // exist at all). The archive helper transitions the lifecycle and
589        // strips the revision from each matching deployment's tracking
590        // list. Traffic splits are untouched.
591        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
592
593        let archived = store
594            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
595                apply_revision_transition(
596                    locked,
597                    rid,
598                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
599                    |_| {},
600                    true,
601                )
602            })
603            .unwrap();
604        assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
605
606        let env = store.load(&env_id).unwrap();
607        assert!(env.bundles[0].current_revisions.is_empty());
608        assert!(env.traffic_splits.is_empty());
609    }
610
611    #[test]
612    fn archive_refuses_when_revision_owns_100_percent_of_a_split() {
613        // The most acute outage path: a single-entry 100%-bps split is the
614        // deployment's only live route. Silent prune would either drop the
615        // route entirely (operational outage) or — with empty-split
616        // cleanup — leave the deployment unreachable. Guard refuses.
617        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
618        let mut env = store.load(&env_id).unwrap();
619        let did = env.bundles[0].deployment_id;
620        env.traffic_splits.push(TrafficSplit {
621            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
622            env_id: env_id.clone(),
623            deployment_id: did,
624            bundle_id: BundleId::new("fast2flow"),
625            generation: 0,
626            entries: vec![TrafficSplitEntry {
627                revision_id: rid,
628                weight_bps: 10_000,
629            }],
630            updated_at: fixed_now(),
631            updated_by: "test".to_string(),
632            idempotency_key: "k1".to_string(),
633            authorization_ref: PathBuf::from("auth.json"),
634            previous_split_ref: None,
635        });
636        env.bundles[0].current_revisions.push(rid);
637        store.save(&env).unwrap();
638
639        let err = store
640            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
641                apply_revision_transition(
642                    locked,
643                    rid,
644                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
645                    |_| {},
646                    true,
647                )
648            })
649            .unwrap_err();
650        match err {
651            LifecycleError::ActiveTrafficReference {
652                revision_id,
653                splits,
654            } => {
655                assert_eq!(revision_id, rid);
656                assert_eq!(splits.len(), 1);
657                assert_eq!(splits[0].deployment_id, did);
658                assert_eq!(splits[0].weight_bps, 10_000);
659            }
660            other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
661        }
662
663        // Nothing persisted: lifecycle still Ready, split still owns the route,
664        // current_revisions still references the revision.
665        let env = store.load(&env_id).unwrap();
666        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
667        assert_eq!(env.traffic_splits.len(), 1);
668        assert_eq!(env.traffic_splits[0].entries.len(), 1);
669        assert!(env.bundles[0].current_revisions.contains(&rid));
670    }
671
672    #[test]
673    fn archive_refuses_when_revision_owns_partial_traffic_in_a_split() {
674        // Multi-entry canary split: archiving the canary revision would
675        // leave the other entries summing to <10_000 bps (a spec invariant
676        // violation that would surface as a save-time SpecError). Guard
677        // refuses before any mutation.
678        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
679        let mut env = store.load(&env_id).unwrap();
680        let did = env.bundles[0].deployment_id;
681
682        // Add a second revision (the "main" line) and a 30/70 canary split.
683        let main_revision = make_revision(did, RevisionLifecycle::Ready);
684        let main_rid = main_revision.revision_id;
685        env.revisions.push(main_revision);
686        env.bundles[0].current_revisions.push(rid);
687        env.bundles[0].current_revisions.push(main_rid);
688        env.traffic_splits.push(TrafficSplit {
689            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
690            env_id: env_id.clone(),
691            deployment_id: did,
692            bundle_id: BundleId::new("fast2flow"),
693            generation: 0,
694            entries: vec![
695                TrafficSplitEntry {
696                    revision_id: rid,
697                    weight_bps: 3_000,
698                },
699                TrafficSplitEntry {
700                    revision_id: main_rid,
701                    weight_bps: 7_000,
702                },
703            ],
704            updated_at: fixed_now(),
705            updated_by: "test".to_string(),
706            idempotency_key: "k1".to_string(),
707            authorization_ref: PathBuf::from("auth.json"),
708            previous_split_ref: None,
709        });
710        store.save(&env).unwrap();
711
712        let err = store
713            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
714                apply_revision_transition(
715                    locked,
716                    rid,
717                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
718                    |_| {},
719                    true,
720                )
721            })
722            .unwrap_err();
723        match err {
724            LifecycleError::ActiveTrafficReference {
725                revision_id,
726                splits,
727            } => {
728                assert_eq!(revision_id, rid);
729                assert_eq!(splits.len(), 1);
730                assert_eq!(splits[0].weight_bps, 3_000);
731            }
732            other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
733        }
734
735        // Split is intact, weights still sum to 10_000.
736        let env = store.load(&env_id).unwrap();
737        let sum: u32 = env.traffic_splits[0]
738            .entries
739            .iter()
740            .map(|e| e.weight_bps)
741            .sum();
742        assert_eq!(sum, 10_000);
743    }
744
745    #[test]
746    fn drain_then_archive_walk_retires_a_live_revision_to_terminal() {
747        // The full operator workflow: a Ready revision is drained, runtime
748        // moves Draining → Inactive (simulated here via a direct save),
749        // operator then archives. The widened archive chain accepts
750        // Draining → Inactive → Archived so the drained revision completes
751        // to the terminal state without manual state edits.
752        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
753        // Simulate the runtime completing the drain.
754        let mut env = store.load(&env_id).unwrap();
755        env.revisions[0].lifecycle = RevisionLifecycle::Inactive;
756        store.save(&env).unwrap();
757
758        let archived = store
759            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
760                apply_revision_transition(
761                    locked,
762                    rid,
763                    &[
764                        (RevisionLifecycle::Staged, RevisionLifecycle::Archived),
765                        (RevisionLifecycle::Warming, RevisionLifecycle::Archived),
766                        (RevisionLifecycle::Ready, RevisionLifecycle::Archived),
767                        (RevisionLifecycle::Failed, RevisionLifecycle::Archived),
768                        (RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
769                        (RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
770                    ],
771                    |_| {},
772                    true,
773                )
774            })
775            .unwrap();
776        assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
777    }
778
779    #[test]
780    fn matrix_walks_every_legal_outbound_edge() {
781        // Drive a revision through every legal `from → to` and assert no
782        // false rejections. We seed a fresh env per case because some
783        // transitions are terminal (Archived) and would block subsequent
784        // iterations.
785        use RevisionLifecycle::*;
786        for (from, to) in &[
787            (Inactive, Staged),
788            (Inactive, Failed),
789            (Inactive, Archived),
790            (Staged, Warming),
791            (Staged, Failed),
792            (Staged, Archived),
793            (Warming, Ready),
794            (Warming, Failed),
795            (Warming, Archived),
796            (Ready, Draining),
797            (Ready, Failed),
798            (Ready, Archived),
799            (Draining, Inactive),
800            (Failed, Staged),
801            (Failed, Archived),
802        ] {
803            let (store, env_id, rid) = seed_one_revision(*from);
804            let result = store.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
805                apply_revision_transition(locked, rid, &[(*from, *to)], |_| {}, false)
806            });
807            assert!(
808                result.is_ok(),
809                "matrix edge `{from:?} → {to:?}` was rejected: {:?}",
810                result.err()
811            );
812            assert_eq!(result.unwrap().lifecycle, *to);
813        }
814    }
815
816    // --- B9 health-gate tests ---------------------------------------------
817
818    /// The warm chain through a passing gate: revision lands `Ready`,
819    /// `on_final` runs (stamps `warmed_at`), env saved.
820    #[test]
821    fn health_gate_pass_advances_chain_and_runs_on_final() {
822        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
823        let revision = store
824            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
825                apply_revision_transition_with_health_gate(
826                    locked,
827                    rid,
828                    &[
829                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
830                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
831                    ],
832                    |r| r.warmed_at = Some(fixed_now()),
833                    false,
834                    |_env, rev| {
835                        // Gate sees the post-chain lifecycle (Ready).
836                        assert_eq!(rev.lifecycle, RevisionLifecycle::Ready);
837                        Ok(())
838                    },
839                )
840            })
841            .unwrap();
842        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
843        assert_eq!(revision.warmed_at, Some(fixed_now()));
844
845        let env = store.load(&env_id).unwrap();
846        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
847        assert_eq!(env.revisions[0].warmed_at, Some(fixed_now()));
848    }
849
850    /// On gate failure, lifecycle flips to `Failed` and is persisted;
851    /// `on_final` does NOT run (no `warmed_at` stamp on failure).
852    #[test]
853    fn health_gate_failure_persists_failed_and_skips_on_final() {
854        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
855        let on_final_ran = std::cell::Cell::new(false);
856        let err = store
857            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
858                apply_revision_transition_with_health_gate(
859                    locked,
860                    rid,
861                    &[
862                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
863                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
864                    ],
865                    |r| {
866                        on_final_ran.set(true);
867                        r.warmed_at = Some(fixed_now());
868                    },
869                    false,
870                    |_env, _rev| {
871                        Err(HealthGateFailure {
872                            failed_checks: vec![
873                                HealthCheckId::RuntimeConfig,
874                                HealthCheckId::SignatureStatus,
875                            ],
876                            message: "synthetic test failure".to_string(),
877                        })
878                    },
879                )
880            })
881            .unwrap_err();
882        match err {
883            LifecycleError::HealthGateFailed {
884                revision_id,
885                failed_checks,
886                message,
887            } => {
888                assert_eq!(revision_id, rid);
889                assert_eq!(
890                    failed_checks,
891                    vec![HealthCheckId::RuntimeConfig, HealthCheckId::SignatureStatus]
892                );
893                assert!(message.contains("synthetic"));
894            }
895            other => panic!("expected HealthGateFailed, got `{other:?}`"),
896        }
897        assert!(
898            !on_final_ran.get(),
899            "on_final must not run on health-gate failure"
900        );
901
902        // On disk: revision is Failed and warmed_at is NOT stamped.
903        let env = store.load(&env_id).unwrap();
904        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
905        assert_eq!(env.revisions[0].warmed_at, None);
906    }
907
908    /// Idempotent retry against an already-final revision must NOT invoke
909    /// the gate and must NOT demote lifecycle on a transient failure —
910    /// `runtime-config.json` is materialized from `traffic_splits`, so
911    /// rerunning a flaky gate on a live Ready revision would persist
912    /// `Failed` while the router keeps serving traffic to it. The retry
913    /// stays a successful no-op; `on_final` re-stamps `warmed_at` per the
914    /// existing idempotent contract.
915    #[test]
916    fn idempotent_retry_skips_gate_and_preserves_ready() {
917        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
918        let gate_invoked = std::cell::Cell::new(false);
919        let revision = store
920            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
921                apply_revision_transition_with_health_gate(
922                    locked,
923                    rid,
924                    &[
925                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
926                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
927                    ],
928                    |r| r.warmed_at = Some(fixed_now()),
929                    false,
930                    |_env, _rev| {
931                        gate_invoked.set(true);
932                        Err(HealthGateFailure {
933                            failed_checks: vec![HealthCheckId::ProviderHealth],
934                            message: "would have demoted a live revision".to_string(),
935                        })
936                    },
937                )
938            })
939            .unwrap();
940        assert!(
941            !gate_invoked.get(),
942            "gate must not run on idempotent retry against an already-final revision"
943        );
944        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
945        assert_eq!(revision.warmed_at, Some(fixed_now()));
946
947        let env = store.load(&env_id).unwrap();
948        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
949    }
950
951    /// The live-traffic protection case Codex flagged: a Ready revision is
952    /// actively serving 100% of a deployment's traffic; a retry warm with
953    /// a (transiently) failing gate must NOT demote it to `Failed` because
954    /// the traffic split still routes to it. After the retry: lifecycle is
955    /// still Ready, the split is intact, the route table stays serviceable.
956    #[test]
957    fn gate_skipped_on_retry_preserves_live_routed_revision() {
958        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
959        let mut env = store.load(&env_id).unwrap();
960        let did = env.bundles[0].deployment_id;
961        env.traffic_splits.push(TrafficSplit {
962            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
963            env_id: env_id.clone(),
964            deployment_id: did,
965            bundle_id: BundleId::new("fast2flow"),
966            generation: 0,
967            entries: vec![TrafficSplitEntry {
968                revision_id: rid,
969                weight_bps: 10_000,
970            }],
971            updated_at: fixed_now(),
972            updated_by: "test".to_string(),
973            idempotency_key: "k1".to_string(),
974            authorization_ref: PathBuf::from("auth.json"),
975            previous_split_ref: None,
976        });
977        store.save(&env).unwrap();
978
979        let revision = store
980            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
981                apply_revision_transition_with_health_gate(
982                    locked,
983                    rid,
984                    &[
985                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
986                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
987                    ],
988                    |_| {},
989                    false,
990                    |_env, _rev| {
991                        Err(HealthGateFailure {
992                            failed_checks: vec![HealthCheckId::ProviderHealth],
993                            message: "transient — must not demote".to_string(),
994                        })
995                    },
996                )
997            })
998            .unwrap();
999        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
1000
1001        // Live traffic split untouched, lifecycle still Ready on disk.
1002        let env = store.load(&env_id).unwrap();
1003        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1004        assert_eq!(env.traffic_splits.len(), 1);
1005        assert_eq!(env.traffic_splits[0].entries.len(), 1);
1006        assert_eq!(env.traffic_splits[0].entries[0].revision_id, rid);
1007        assert_eq!(env.traffic_splits[0].entries[0].weight_bps, 10_000);
1008    }
1009
1010    /// Gate-aware path with the gate-less default (Noop closure) is the
1011    /// public surface the existing `apply_revision_transition` wraps.
1012    /// Sanity-check that wrapper preserves the original behavior verbatim.
1013    #[test]
1014    fn apply_revision_transition_remains_a_noop_gate_wrapper() {
1015        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
1016        let revision = store
1017            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1018                apply_revision_transition(
1019                    locked,
1020                    rid,
1021                    &[
1022                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
1023                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
1024                    ],
1025                    |r| r.warmed_at = Some(fixed_now()),
1026                    false,
1027                )
1028            })
1029            .unwrap();
1030        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
1031        assert_eq!(revision.warmed_at, Some(fixed_now()));
1032    }
1033
1034    /// Defensive: a chain whose final state cannot transition to `Failed`
1035    /// (`Draining → Failed` is not in the spec matrix) surfaces an
1036    /// `InvalidTransition` rather than silently corrupting the lifecycle.
1037    /// The env must remain untouched on disk because we never invoke
1038    /// `locked.save` on this branch.
1039    #[test]
1040    fn gate_failure_with_no_legal_failed_transition_surfaces_invalid_transition() {
1041        // Seed Ready, drive the legal `Ready → Draining` hop, then have the
1042        // gate reject. `Draining → Failed` is not in the spec matrix, so
1043        // the helper must bail with InvalidTransition without persisting.
1044        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
1045        let err = store
1046            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1047                apply_revision_transition_with_health_gate(
1048                    locked,
1049                    rid,
1050                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Draining)],
1051                    |_| {},
1052                    false,
1053                    |_env, _rev| {
1054                        Err(HealthGateFailure {
1055                            failed_checks: vec![HealthCheckId::RouteTable],
1056                            message: "unreachable in practice for drain".to_string(),
1057                        })
1058                    },
1059                )
1060            })
1061            .unwrap_err();
1062        match err {
1063            LifecycleError::InvalidTransition { from, to } => {
1064                assert_eq!(from, RevisionLifecycle::Draining);
1065                assert_eq!(to, RevisionLifecycle::Failed);
1066            }
1067            other => panic!("expected InvalidTransition, got `{other:?}`"),
1068        }
1069
1070        // No save happened — revision still in `Ready`.
1071        let env = store.load(&env_id).unwrap();
1072        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1073    }
1074
1075    /// The gate runs AFTER the `prune_from_splits` active-refs guard, so a
1076    /// would-be archive that's blocked by live traffic refuses with the
1077    /// active-traffic error and never invokes the gate — guarding against
1078    /// running an arbitrary (possibly expensive) gate against a state the
1079    /// helper won't transition to.
1080    #[test]
1081    fn gate_is_not_invoked_when_prune_guard_refuses() {
1082        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
1083        let mut env = store.load(&env_id).unwrap();
1084        let did = env.bundles[0].deployment_id;
1085        env.traffic_splits.push(TrafficSplit {
1086            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
1087            env_id: env_id.clone(),
1088            deployment_id: did,
1089            bundle_id: BundleId::new("fast2flow"),
1090            generation: 0,
1091            entries: vec![TrafficSplitEntry {
1092                revision_id: rid,
1093                weight_bps: 10_000,
1094            }],
1095            updated_at: fixed_now(),
1096            updated_by: "test".to_string(),
1097            idempotency_key: "k1".to_string(),
1098            authorization_ref: PathBuf::from("auth.json"),
1099            previous_split_ref: None,
1100        });
1101        store.save(&env).unwrap();
1102
1103        let gate_invoked = std::cell::Cell::new(false);
1104        let err = store
1105            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1106                apply_revision_transition_with_health_gate(
1107                    locked,
1108                    rid,
1109                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
1110                    |_| {},
1111                    true,
1112                    |_env, _rev| {
1113                        gate_invoked.set(true);
1114                        Ok(())
1115                    },
1116                )
1117            })
1118            .unwrap_err();
1119        assert!(matches!(err, LifecycleError::ActiveTrafficReference { .. }));
1120        assert!(
1121            !gate_invoked.get(),
1122            "gate must not run when prune guard refuses"
1123        );
1124    }
1125
1126    /// An empty `failed_checks` list is allowed (e.g. a gate that aborted
1127    /// before any check completed) — `message` carries the detail. The
1128    /// helper still flips to Failed and persists.
1129    #[test]
1130    fn gate_failure_with_empty_failed_checks_is_still_persisted() {
1131        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
1132        let err = store
1133            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
1134                apply_revision_transition_with_health_gate(
1135                    locked,
1136                    rid,
1137                    &[
1138                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
1139                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
1140                    ],
1141                    |_| {},
1142                    false,
1143                    |_env, _rev| {
1144                        Err(HealthGateFailure {
1145                            failed_checks: Vec::new(),
1146                            message: "gate aborted before any check completed".to_string(),
1147                        })
1148                    },
1149                )
1150            })
1151            .unwrap_err();
1152        match err {
1153            LifecycleError::HealthGateFailed {
1154                failed_checks,
1155                message,
1156                ..
1157            } => {
1158                assert!(failed_checks.is_empty());
1159                assert!(message.contains("aborted"));
1160            }
1161            other => panic!("expected HealthGateFailed, got `{other:?}`"),
1162        }
1163        let env = store.load(&env_id).unwrap();
1164        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1165    }
1166}