greentic-deployer-dev 1.1.26149398477

Greentic deployer runtime for plan construction and deployment-pack dispatch
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
//! Revision-lifecycle storage guard (A5 of `plans/next-gen-deployment.md`).
//!
//! Wraps the pure [`greentic_deploy_spec::is_valid_transition`] predicate
//! from `greentic-deploy-spec` with the storage-side semantics that
//! operator commands and B-phase orchestrators need:
//!
//! - load the env from a [`Locked<'_>`](crate::environment::Locked) transaction,
//! - find the revision by id (typed `NotFound`),
//! - walk an `accepted_chain` of `(from, to)` edges, advancing the revision
//!   through every legal hop until it lands in the final state,
//! - report a typed `InvalidTransition` for any edge the spec rejects, and a
//!   typed `Conflict` for a revision that started outside the chain,
//! - optionally prune the revision from every traffic split and from each
//!   matching `BundleDeployment.current_revisions` (the archive path),
//! - save the env back through the same `Locked<'_>` handle so the per-env
//!   flock spans the whole load → mutate → save critical section.
//!
//! `cli::revisions::{stage, warm, drain, archive}` delegate the inner body
//! of their `transact` closures to [`apply_revision_transition`], and the
//! same helper is the entrypoint future B-phase consumers (gtc start
//! orchestration #221, B9 warm/ready gate, A7 audit emission) call when
//! they need to drive a revision through the matrix.
//!
//! The helper does **not** mint revisions — `stage` constructs the
//! `Revision` struct itself and pushes it onto `Environment.revisions`. The
//! lifecycle guard only owns transitions between *existing* revisions.

use greentic_deploy_spec::{
    BundleId, DeploymentId, Revision, RevisionId, RevisionLifecycle, is_valid_transition,
};
use thiserror::Error;

use crate::environment::{Locked, StoreError};

/// Identifies a `TrafficSplit` (by its `(deployment_id, bundle_id)` key)
/// for error-reporting purposes. Surfaced in
/// [`LifecycleError::ActiveTrafficReference`] so operators can locate the
/// splits they need to rebalance before retrying the archive.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActiveSplitRef {
    pub deployment_id: DeploymentId,
    pub bundle_id: BundleId,
    /// Weight (basis points) of the *archived* revision's entry in this
    /// split. Lets callers distinguish "100% live route" from "partial
    /// canary" without re-loading the env.
    pub weight_bps: u32,
}

/// Errors produced by [`apply_revision_transition`]. Cleanly maps onto
/// `cli::OpError` via the `From` impl in `cli/mod.rs`.
#[derive(Debug, Error)]
pub enum LifecycleError {
    /// The targeted revision was not present in the loaded environment.
    #[error("revision `{revision_id}` not found in env `{env_id}`")]
    NotFound {
        env_id: greentic_deploy_spec::EnvId,
        revision_id: RevisionId,
    },
    /// The spec matrix rejected an edge inside the requested chain. This is
    /// a programming error in the caller, not a runtime conflict — the
    /// helper should never see this in production because every chain
    /// passed in from `cli::revisions` is hand-curated. Surfaced as a
    /// distinct variant so call sites can choose to panic in debug.
    #[error("spec rejects transition `{from:?} → {to:?}`")]
    InvalidTransition {
        from: RevisionLifecycle,
        to: RevisionLifecycle,
    },
    /// The revision was loaded in a state that does not start any edge in
    /// the accepted chain. `actual` carries the lifecycle the helper found;
    /// `expected_starts` lists the `from` states of each accepted edge so
    /// callers can render a useful error.
    #[error(
        "revision `{revision_id}` is in `{actual:?}`; expected one of {expected_starts:?} to apply the requested transition"
    )]
    Conflict {
        revision_id: RevisionId,
        actual: RevisionLifecycle,
        expected_starts: Vec<RevisionLifecycle>,
    },
    /// The caller passed an empty `accepted_chain`. Internal-API misuse.
    #[error("transition chain is empty; cannot apply any state change")]
    EmptyChain,
    /// The archive path (`prune_from_splits = true`) was invoked against a
    /// revision still referenced by one or more live traffic splits.
    /// Blindly pruning the entry would either silently drop the route
    /// (100%-single-entry split) or produce a split whose weights no
    /// longer sum to 10,000 bps (the spec invariant), so the helper
    /// refuses. Callers must rebalance traffic via `gtc op traffic set`
    /// before retrying. `splits` carries the offending references for
    /// rendering an actionable error.
    #[error(
        "revision `{revision_id}` is still referenced by {} live traffic split(s); rebalance via `gtc op traffic set` before archiving", splits.len()
    )]
    ActiveTrafficReference {
        revision_id: RevisionId,
        splits: Vec<ActiveSplitRef>,
    },
    /// Underlying storage layer failure (load/save through the `Locked<'_>`).
    #[error(transparent)]
    Store(#[from] StoreError),
}

/// Apply a revision lifecycle transition under an already-held env lock.
///
/// `accepted_chain` is a list of `(from, to)` edges, applied in order: the
/// helper finds the revision, then for each edge whose `from` matches the
/// revision's current lifecycle, it validates the edge against the spec
/// matrix and advances the revision. The chain terminates when no further
/// edge's `from` matches the (now mutated) revision, and the helper
/// asserts the revision has reached the final edge's `to` state.
///
/// **Idempotent on the final state:** if the revision starts already in
/// the chain's final state, the helper succeeds without raising
/// Conflict. `on_final` still runs (e.g. re-stamps `warmed_at`) and the
/// env is still saved through the lock; callers needing strict
/// once-only semantics must check the loaded state themselves. Conflict
/// surfaces only when the loaded state is neither one of the chain's
/// `from` states nor the chain's final `to` state.
///
/// `on_final` runs once after the last advance, on the freshly-mutated
/// [`Revision`] reference, before the env is saved. Use it to stamp
/// timestamps like `warmed_at`. The helper expects `FnOnce` because each
/// transition is a one-shot.
///
/// `prune_from_splits = true` is the archive-path knob. Before pruning,
/// the helper scans every `TrafficSplit.entries` for references to the
/// archived revision: if any are found, it refuses with
/// [`LifecycleError::ActiveTrafficReference`] (no save, no mutation) so
/// the operator can rebalance traffic through `gtc op traffic set`
/// first. If no live references exist, the revision is removed from each
/// [`BundleDeployment::current_revisions`](greentic_deploy_spec::BundleDeployment::current_revisions)
/// whose `deployment_id` matches the archived revision's (a tracking
/// field, not a routing-impact one). Empty traffic splits are not
/// possible at this point because the guard would have caught them; the
/// invariant is preserved across the save.
///
/// The helper saves the env through `locked.save(...)` before returning,
/// so the entire read-modify-write completes inside the caller's
/// `LocalFsStore::transact` lock scope. On any error, no save is performed
/// and the on-disk env remains untouched.
///
/// Returns the post-transition [`Revision`] (cloned out of the saved env)
/// so callers can render summaries or emit audit events without re-loading.
pub fn apply_revision_transition<F>(
    locked: &Locked<'_>,
    revision_id: RevisionId,
    accepted_chain: &[(RevisionLifecycle, RevisionLifecycle)],
    on_final: F,
    prune_from_splits: bool,
) -> Result<Revision, LifecycleError>
where
    F: FnOnce(&mut Revision),
{
    if accepted_chain.is_empty() {
        return Err(LifecycleError::EmptyChain);
    }

    let mut env = locked.load()?;
    let idx = env
        .revisions
        .iter()
        .position(|r| r.revision_id == revision_id)
        .ok_or_else(|| LifecycleError::NotFound {
            env_id: locked.env_id().clone(),
            revision_id,
        })?;

    for (from, to) in accepted_chain {
        if env.revisions[idx].lifecycle == *from {
            if !is_valid_transition(*from, *to) {
                return Err(LifecycleError::InvalidTransition {
                    from: *from,
                    to: *to,
                });
            }
            env.revisions[idx].lifecycle = *to;
        }
    }

    let final_state = accepted_chain
        .last()
        .map(|(_, to)| *to)
        .expect("chain non-empty: checked above");

    if env.revisions[idx].lifecycle != final_state {
        let expected_starts = accepted_chain.iter().map(|(from, _)| *from).collect();
        return Err(LifecycleError::Conflict {
            revision_id,
            actual: env.revisions[idx].lifecycle,
            expected_starts,
        });
    }

    if prune_from_splits {
        // Refuse to archive a revision that still routes live traffic.
        // Blindly pruning would either silently drop the route (100%
        // single-entry split) or produce weights that no longer sum to
        // 10,000 bps (the spec invariant — `locked.save` would reject
        // with a SpecError that doesn't explain the live-traffic angle).
        let active_refs: Vec<ActiveSplitRef> = env
            .traffic_splits
            .iter()
            .flat_map(|split| {
                split
                    .entries
                    .iter()
                    .filter(|entry| entry.revision_id == revision_id)
                    .map(|entry| ActiveSplitRef {
                        deployment_id: split.deployment_id,
                        bundle_id: split.bundle_id.clone(),
                        weight_bps: entry.weight_bps,
                    })
            })
            .collect();
        if !active_refs.is_empty() {
            return Err(LifecycleError::ActiveTrafficReference {
                revision_id,
                splits: active_refs,
            });
        }
    }

    on_final(&mut env.revisions[idx]);

    if prune_from_splits {
        // No live traffic references at this point (guard above).
        // Remove the revision from each matching deployment's tracking
        // list; traffic splits themselves are untouched (none referenced
        // this revision anyway).
        let deployment_id = env.revisions[idx].deployment_id;
        for bundle in env.bundles.iter_mut() {
            if bundle.deployment_id == deployment_id {
                bundle.current_revisions.retain(|rid| *rid != revision_id);
            }
        }
    }

    locked.save(&env)?;
    Ok(env.revisions[idx].clone())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::environment::{EnvironmentStore, LocalFsStore};
    use chrono::{TimeZone, Utc};
    use greentic_deploy_spec::{
        BundleDeployment, BundleDeploymentStatus, BundleId, CustomerId, DeploymentId, EnvId,
        Environment, EnvironmentHostConfig, PackId, PackListEntry, PartyId, RevenueShareEntry,
        Revision, RevisionId, RevisionLifecycle, RouteBinding, SchemaVersion, SemVer,
        TenantSelector, TrafficSplit, TrafficSplitEntry,
    };
    use std::path::PathBuf;
    use tempfile::tempdir;

    const ENV_ID: &str = "local";

    fn env_id() -> EnvId {
        EnvId::try_from(ENV_ID).unwrap()
    }

    fn fixed_now() -> chrono::DateTime<Utc> {
        Utc.with_ymd_and_hms(2026, 5, 19, 12, 0, 0).unwrap()
    }

    fn make_env() -> Environment {
        Environment {
            schema: SchemaVersion::new(SchemaVersion::ENVIRONMENT_V1),
            environment_id: env_id(),
            name: ENV_ID.to_string(),
            host_config: EnvironmentHostConfig {
                env_id: env_id(),
                region: None,
                tenant_org_id: None,
            },
            packs: Vec::new(),
            credentials_ref: None,
            bundles: Vec::new(),
            revisions: Vec::new(),
            traffic_splits: Vec::new(),
            revocation: Default::default(),
            retention: Default::default(),
            health: Default::default(),
        }
    }

    fn make_revision(deployment_id: DeploymentId, lifecycle: RevisionLifecycle) -> Revision {
        Revision {
            schema: SchemaVersion::new(SchemaVersion::REVISION_V1),
            revision_id: RevisionId::new(),
            env_id: env_id(),
            bundle_id: BundleId::new("fast2flow"),
            deployment_id,
            sequence: 1,
            created_at: fixed_now(),
            bundle_digest: "sha256:00".to_string(),
            pack_list: vec![PackListEntry {
                pack_id: PackId::new("greentic.test.pack"),
                version: SemVer::new(1, 0, 0),
                digest: "sha256:00".to_string(),
                source_uri: None,
            }],
            pack_list_lock_ref: PathBuf::from("pack-list.lock"),
            config_digest: "sha256:00".to_string(),
            signature_sidecar_ref: PathBuf::from("rev.sig"),
            lifecycle,
            staged_at: None,
            warmed_at: None,
            drain_seconds: 30,
            abort_metrics: Vec::new(),
        }
    }

    fn make_bundle_deployment() -> BundleDeployment {
        BundleDeployment {
            schema: SchemaVersion::new(SchemaVersion::BUNDLE_DEPLOYMENT_V1),
            deployment_id: DeploymentId::new(),
            env_id: env_id(),
            bundle_id: BundleId::new("fast2flow"),
            customer_id: CustomerId::new("local-dev"),
            status: BundleDeploymentStatus::Active,
            current_revisions: Vec::new(),
            route_binding: RouteBinding {
                hosts: vec!["fast2flow.local".to_string()],
                path_prefixes: Vec::new(),
                tenant_selector: TenantSelector {
                    tenant: "default".to_string(),
                    team: "default".to_string(),
                },
            },
            revenue_share: vec![RevenueShareEntry {
                party_id: PartyId::new("greentic"),
                basis_points: 10_000,
            }],
            revenue_policy_ref: PathBuf::from("revenue.json"),
            usage: None,
            created_at: fixed_now(),
            authorization_ref: PathBuf::from("auth.json"),
        }
    }

    /// Build an env with one bundle + one revision in the given lifecycle.
    /// Returns `(store, env_id, revision_id)`.
    fn seed_one_revision(lifecycle: RevisionLifecycle) -> (LocalFsStore, EnvId, RevisionId) {
        let dir = tempdir().unwrap();
        let store = LocalFsStore::new(dir.path().to_path_buf());
        let mut env = make_env();
        let bundle = make_bundle_deployment();
        let did = bundle.deployment_id;
        let revision = make_revision(did, lifecycle);
        let rid = revision.revision_id;
        env.bundles.push(bundle);
        env.bundles[0].current_revisions.push(rid);
        env.revisions.push(revision);
        store.save(&env).unwrap();
        // Leak the tempdir into the returned store so the dir survives test scope.
        // (LocalFsStore holds its root by value; the tempdir guard is dropped at
        // function return, but our root is already inside the tempdir's path.
        // To survive, we extract the path and keep the dir alive via std::mem::forget.)
        std::mem::forget(dir);
        (store, env_id(), rid)
    }

    #[test]
    fn applies_two_hop_chain_to_final_state() {
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
        let revision = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[
                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
                    ],
                    |_| {},
                    false,
                )
            })
            .unwrap();
        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);

        // Persisted on disk.
        let env = store.load(&env_id).unwrap();
        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
    }

    #[test]
    fn on_final_runs_once_on_post_advance_revision() {
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
        let revision = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[
                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
                    ],
                    |r| {
                        r.warmed_at = Some(fixed_now());
                    },
                    false,
                )
            })
            .unwrap();
        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
        assert_eq!(revision.warmed_at, Some(fixed_now()));
    }

    #[test]
    fn missing_revision_surfaces_not_found_without_touching_env() {
        let (store, env_id, _rid) = seed_one_revision(RevisionLifecycle::Staged);
        let ghost = RevisionId::new();
        let err = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    ghost,
                    &[(RevisionLifecycle::Staged, RevisionLifecycle::Warming)],
                    |_| {},
                    false,
                )
            })
            .unwrap_err();
        match err {
            LifecycleError::NotFound {
                revision_id,
                env_id: e,
            } => {
                assert_eq!(revision_id, ghost);
                assert_eq!(e, env_id);
            }
            other => panic!("expected NotFound, got `{other:?}`"),
        }

        // Original revision still in `Staged` on disk.
        let env = store.load(&env_id).unwrap();
        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Staged);
    }

    #[test]
    fn revision_outside_chain_surfaces_conflict() {
        // Seed in `Draining`, request the warm chain `Staged → Warming → Ready`.
        // `Draining` matches neither edge's `from` and isn't the chain's final
        // state, so the helper must surface a Conflict without mutating state.
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
        let err = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[
                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
                    ],
                    |_| {},
                    false,
                )
            })
            .unwrap_err();
        match err {
            LifecycleError::Conflict {
                revision_id,
                actual,
                expected_starts,
            } => {
                assert_eq!(revision_id, rid);
                assert_eq!(actual, RevisionLifecycle::Draining);
                assert_eq!(
                    expected_starts,
                    vec![RevisionLifecycle::Staged, RevisionLifecycle::Warming]
                );
            }
            other => panic!("expected Conflict, got `{other:?}`"),
        }

        // No save happened — env still has the revision in its original state.
        let env = store.load(&env_id).unwrap();
        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
    }

    #[test]
    fn already_in_final_state_is_idempotent_success() {
        // Seed in `Ready`, request the warm chain `Staged → Warming → Ready`.
        // No edge applies, but the revision is already at the final state →
        // helper returns Ok (idempotent retry semantics).
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
        let revision = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[
                        (RevisionLifecycle::Staged, RevisionLifecycle::Warming),
                        (RevisionLifecycle::Warming, RevisionLifecycle::Ready),
                    ],
                    |_| {},
                    false,
                )
            })
            .unwrap();
        assert_eq!(revision.lifecycle, RevisionLifecycle::Ready);
    }

    #[test]
    fn empty_chain_returns_empty_chain_error() {
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Staged);
        let err = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(locked, rid, &[], |_| {}, false)
            })
            .unwrap_err();
        assert!(matches!(err, LifecycleError::EmptyChain));
    }

    #[test]
    fn archive_succeeds_and_prunes_current_revisions_when_no_live_traffic() {
        // Happy path: revision is not in any traffic split (or no splits
        // exist at all). The archive helper transitions the lifecycle and
        // strips the revision from each matching deployment's tracking
        // list. Traffic splits are untouched.
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);

        let archived = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
                    |_| {},
                    true,
                )
            })
            .unwrap();
        assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);

        let env = store.load(&env_id).unwrap();
        assert!(env.bundles[0].current_revisions.is_empty());
        assert!(env.traffic_splits.is_empty());
    }

    #[test]
    fn archive_refuses_when_revision_owns_100_percent_of_a_split() {
        // The most acute outage path: a single-entry 100%-bps split is the
        // deployment's only live route. Silent prune would either drop the
        // route entirely (operational outage) or — with empty-split
        // cleanup — leave the deployment unreachable. Guard refuses.
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
        let mut env = store.load(&env_id).unwrap();
        let did = env.bundles[0].deployment_id;
        env.traffic_splits.push(TrafficSplit {
            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
            env_id: env_id.clone(),
            deployment_id: did,
            bundle_id: BundleId::new("fast2flow"),
            generation: 0,
            entries: vec![TrafficSplitEntry {
                revision_id: rid,
                weight_bps: 10_000,
            }],
            updated_at: fixed_now(),
            updated_by: "test".to_string(),
            idempotency_key: "k1".to_string(),
            authorization_ref: PathBuf::from("auth.json"),
            previous_split_ref: None,
        });
        env.bundles[0].current_revisions.push(rid);
        store.save(&env).unwrap();

        let err = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
                    |_| {},
                    true,
                )
            })
            .unwrap_err();
        match err {
            LifecycleError::ActiveTrafficReference {
                revision_id,
                splits,
            } => {
                assert_eq!(revision_id, rid);
                assert_eq!(splits.len(), 1);
                assert_eq!(splits[0].deployment_id, did);
                assert_eq!(splits[0].weight_bps, 10_000);
            }
            other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
        }

        // Nothing persisted: lifecycle still Ready, split still owns the route,
        // current_revisions still references the revision.
        let env = store.load(&env_id).unwrap();
        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
        assert_eq!(env.traffic_splits.len(), 1);
        assert_eq!(env.traffic_splits[0].entries.len(), 1);
        assert!(env.bundles[0].current_revisions.contains(&rid));
    }

    #[test]
    fn archive_refuses_when_revision_owns_partial_traffic_in_a_split() {
        // Multi-entry canary split: archiving the canary revision would
        // leave the other entries summing to <10_000 bps (a spec invariant
        // violation that would surface as a save-time SpecError). Guard
        // refuses before any mutation.
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Ready);
        let mut env = store.load(&env_id).unwrap();
        let did = env.bundles[0].deployment_id;

        // Add a second revision (the "main" line) and a 30/70 canary split.
        let main_revision = make_revision(did, RevisionLifecycle::Ready);
        let main_rid = main_revision.revision_id;
        env.revisions.push(main_revision);
        env.bundles[0].current_revisions.push(rid);
        env.bundles[0].current_revisions.push(main_rid);
        env.traffic_splits.push(TrafficSplit {
            schema: SchemaVersion::new(SchemaVersion::TRAFFIC_SPLIT_V1),
            env_id: env_id.clone(),
            deployment_id: did,
            bundle_id: BundleId::new("fast2flow"),
            generation: 0,
            entries: vec![
                TrafficSplitEntry {
                    revision_id: rid,
                    weight_bps: 3_000,
                },
                TrafficSplitEntry {
                    revision_id: main_rid,
                    weight_bps: 7_000,
                },
            ],
            updated_at: fixed_now(),
            updated_by: "test".to_string(),
            idempotency_key: "k1".to_string(),
            authorization_ref: PathBuf::from("auth.json"),
            previous_split_ref: None,
        });
        store.save(&env).unwrap();

        let err = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[(RevisionLifecycle::Ready, RevisionLifecycle::Archived)],
                    |_| {},
                    true,
                )
            })
            .unwrap_err();
        match err {
            LifecycleError::ActiveTrafficReference {
                revision_id,
                splits,
            } => {
                assert_eq!(revision_id, rid);
                assert_eq!(splits.len(), 1);
                assert_eq!(splits[0].weight_bps, 3_000);
            }
            other => panic!("expected ActiveTrafficReference, got `{other:?}`"),
        }

        // Split is intact, weights still sum to 10_000.
        let env = store.load(&env_id).unwrap();
        let sum: u32 = env.traffic_splits[0]
            .entries
            .iter()
            .map(|e| e.weight_bps)
            .sum();
        assert_eq!(sum, 10_000);
    }

    #[test]
    fn drain_then_archive_walk_retires_a_live_revision_to_terminal() {
        // The full operator workflow: a Ready revision is drained, runtime
        // moves Draining → Inactive (simulated here via a direct save),
        // operator then archives. The widened archive chain accepts
        // Draining → Inactive → Archived so the drained revision completes
        // to the terminal state without manual state edits.
        let (store, env_id, rid) = seed_one_revision(RevisionLifecycle::Draining);
        // Simulate the runtime completing the drain.
        let mut env = store.load(&env_id).unwrap();
        env.revisions[0].lifecycle = RevisionLifecycle::Inactive;
        store.save(&env).unwrap();

        let archived = store
            .transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(
                    locked,
                    rid,
                    &[
                        (RevisionLifecycle::Staged, RevisionLifecycle::Archived),
                        (RevisionLifecycle::Warming, RevisionLifecycle::Archived),
                        (RevisionLifecycle::Ready, RevisionLifecycle::Archived),
                        (RevisionLifecycle::Failed, RevisionLifecycle::Archived),
                        (RevisionLifecycle::Draining, RevisionLifecycle::Inactive),
                        (RevisionLifecycle::Inactive, RevisionLifecycle::Archived),
                    ],
                    |_| {},
                    true,
                )
            })
            .unwrap();
        assert_eq!(archived.lifecycle, RevisionLifecycle::Archived);
    }

    #[test]
    fn matrix_walks_every_legal_outbound_edge() {
        // Drive a revision through every legal `from → to` and assert no
        // false rejections. We seed a fresh env per case because some
        // transitions are terminal (Archived) and would block subsequent
        // iterations.
        use RevisionLifecycle::*;
        for (from, to) in &[
            (Inactive, Staged),
            (Inactive, Failed),
            (Inactive, Archived),
            (Staged, Warming),
            (Staged, Failed),
            (Staged, Archived),
            (Warming, Ready),
            (Warming, Failed),
            (Warming, Archived),
            (Ready, Draining),
            (Ready, Failed),
            (Ready, Archived),
            (Draining, Inactive),
            (Failed, Staged),
            (Failed, Archived),
        ] {
            let (store, env_id, rid) = seed_one_revision(*from);
            let result = store.transact(&env_id, |locked| -> Result<Revision, LifecycleError> {
                apply_revision_transition(locked, rid, &[(*from, *to)], |_| {}, false)
            });
            assert!(
                result.is_ok(),
                "matrix edge `{from:?} → {to:?}` was rejected: {:?}",
                result.err()
            );
            assert_eq!(result.unwrap().lifecycle, *to);
        }
    }
}