Skip to main content

greentic_deployer/cli/
revisions.rs

1//! `gtc op revisions {stage,warm,drain,archive,list}` (`A3`).
2//!
3//! Manages `Environment.revisions: Vec<Revision>` and the per-deployment
4//! `current_revisions` list on each `BundleDeployment`. Lifecycle transitions
5//! are validated against the spec's pure
6//! [`is_valid_transition`]
7//! predicate so the operator can't put a Revision into an impossible state.
8//!
9//! Heavy lifting deferred:
10//!
11//! - Bundle-archive staging (resolving `.gtbundle` → `pack_list` via the
12//!   distributor-client's `stage_bundle` call, digest-verifying the
13//!   artifact, and writing the pack-list lockfile atomically before the
14//!   revision becomes `Staged`) is **Phase D** work — tracked at
15//!   <https://github.com/greenticai/greentic-deployer/issues/209>. Today
16//!   `stage` trusts caller-supplied `bundle_digest` / `pack_list` /
17//!   `*_ref` fields verbatim. A5 delivered the lifecycle storage guard
18//!   (`environment::lifecycle::apply_revision_transition`) and the
19//!   distributor-client's `set_bundle_state` atomic-write + transition
20//!   matrix, but did NOT integrate `stage_bundle` here.
21//! - Runner warm/drain hooks (route-table build, in-flight session
22//!   accounting) are owned by `greentic-start`; A3 only updates the
23//!   lifecycle bit and stamps `warmed_at`. The full warm/drain dance lands
24//!   when the dispatcher lands.
25
26use std::path::PathBuf;
27
28use greentic_deploy_spec::{
29    BundleId, DeploymentId, EnvId, PackId, PackListEntry, Revision, RevisionId, RevisionLifecycle,
30    SemVer, is_valid_transition,
31};
32use serde::{Deserialize, Serialize};
33use serde_json::{Value, json};
34
35use crate::environment::{
36    EnvironmentReads, EnvironmentStore, LocalFsStore, StageRevisionPayload, WarmRevisionPayload,
37};
38use crate::rollout_telemetry::emit_lifecycle_event;
39use greentic_deploy_spec::Environment;
40use greentic_telemetry::RolloutEvent;
41
42use super::{
43    AuditCtx, OpError, OpFlags, OpOutcome, audit_and_record, map_store_err_preserving_noun,
44    mint_idempotency_key,
45};
46
47const NOUN: &str = "revisions";
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct RevisionStagePayload {
51    pub environment_id: String,
52    pub deployment_id: String,
53    /// Stable revision id for safe lost-response retries against an HTTP store
54    /// (A8 §2). When absent, one is minted per invocation — fine for a one-shot
55    /// local stage, but a bare remote retry then mints a *different* id (and
56    /// key), changing the request fingerprint so the server's replay ledger
57    /// can't dedup it. Pin it together with `idempotency_key` for retry-safe
58    /// remote staging. Honored by the remote (`--store-url`) path; the local
59    /// path always mints.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub revision_id: Option<String>,
62    /// Caller-supplied A8 §2 idempotency key. Optional for back-compat; when
63    /// absent, one is minted per invocation. Supply a stable key (with
64    /// `revision_id`) for safe lost-response retries against the HTTP store.
65    /// Honored by the remote (`--store-url`) path; the local path always mints.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub idempotency_key: Option<String>,
68    /// Local `.gtbundle` to resolve. When set, the bundle is extracted under
69    /// the revision dir and its embedded `.gtpack`s are pinned into
70    /// `pack-list.lock` — `bundle_digest` / `pack_list` / `pack_list_lock_ref`
71    /// are then derived from the artifact and any caller-supplied values for
72    /// those fields are ignored. When unset, the legacy path records the
73    /// caller-supplied pointers verbatim.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub bundle_path: Option<PathBuf>,
76    #[serde(default = "default_bundle_digest")]
77    pub bundle_digest: String,
78    /// Registry reference the bundle was resolved from (`oci://…`, `repo://…`,
79    /// `store://…`), asserted by the caller that resolved it. Recorded on the
80    /// revision so a remote worker can pull the bundle at boot; `None` for a
81    /// locally-staged bundle. The deployer never derives this — by the time
82    /// `stage` runs the `--bundle` path already holds a local file — so the
83    /// caller threads it through. `bundle_digest` is the integrity backstop on
84    /// whatever the worker pulls.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub bundle_source_uri: Option<String>,
87    #[serde(default)]
88    pub pack_list: Vec<PackListEntryPayload>,
89    /// Env-relative pack-list lockfile. Empty (the default) means "no lock
90    /// written": the runtime-config materializer only surfaces a non-empty
91    /// ref, so an unstaged/legacy revision never points greentic-start at a
92    /// file that does not exist. The `--bundle` path overwrites this with the
93    /// real `revisions/<rev>/pack-list.lock` it writes.
94    #[serde(default)]
95    pub pack_list_lock_ref: PathBuf,
96    #[serde(default = "default_config_digest")]
97    pub config_digest: String,
98    #[serde(default = "default_signature_sidecar_ref")]
99    pub signature_sidecar_ref: PathBuf,
100    #[serde(default = "default_drain_seconds")]
101    pub drain_seconds: u32,
102}
103
104pub(super) fn default_bundle_digest() -> String {
105    "sha256:00".to_string()
106}
107pub(super) fn default_config_digest() -> String {
108    "sha256:00".to_string()
109}
110pub(super) fn default_signature_sidecar_ref() -> PathBuf {
111    PathBuf::from("rev.sig")
112}
113pub(super) fn default_drain_seconds() -> u32 {
114    30
115}
116
117/// Convert wire-shaped [`PackListEntryPayload`]s into pinned [`PackListEntry`]s,
118/// validating each version string. Shared by the local stage (`--answers`
119/// legacy path) and the remote `--store-url` dispatch.
120pub(super) fn parse_pack_list(
121    entries: Vec<PackListEntryPayload>,
122) -> Result<Vec<PackListEntry>, OpError> {
123    entries
124        .into_iter()
125        .map(|e| {
126            Ok::<_, OpError>(PackListEntry {
127                pack_id: PackId::new(e.pack_id),
128                version: e
129                    .version
130                    .parse::<SemVer>()
131                    .map_err(|err| OpError::InvalidArgument(format!("pack version: {err}")))?,
132                digest: e.digest,
133                source_uri: e.source_uri,
134            })
135        })
136        .collect()
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PackListEntryPayload {
141    pub pack_id: String,
142    pub version: String,
143    pub digest: String,
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub source_uri: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct RevisionTransitionPayload {
150    pub environment_id: String,
151    pub revision_id: String,
152    /// Caller-supplied A8 §2 idempotency key. Optional on the CLI surface
153    /// for back-compat; when absent, [`typed_transition`] mints one per
154    /// CLI invocation. Operators wanting safe lost-response retries
155    /// (HTTP backend, PR-3b) supply a stable key in their payload so the
156    /// server can replay the original outcome instead of applying a
157    /// second mutation.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub idempotency_key: Option<String>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct RevisionSummary {
164    pub revision_id: String,
165    pub deployment_id: String,
166    pub bundle_id: String,
167    pub sequence: u64,
168    pub lifecycle: RevisionLifecycle,
169}
170
171impl From<&Revision> for RevisionSummary {
172    fn from(r: &Revision) -> Self {
173        Self {
174            revision_id: r.revision_id.to_string(),
175            deployment_id: r.deployment_id.to_string(),
176            bundle_id: r.bundle_id.as_str().to_string(),
177            sequence: r.sequence,
178            lifecycle: r.lifecycle,
179        }
180    }
181}
182
183/// `op revisions stage`. Creates a Revision at `inactive → staged`. Bumps
184/// the sequence to one past the deployment's current max.
185pub fn stage(
186    store: &LocalFsStore,
187    flags: &OpFlags,
188    payload: Option<RevisionStagePayload>,
189) -> Result<OpOutcome, OpError> {
190    if flags.schema_only {
191        return Ok(OpOutcome::new(NOUN, "stage", stage_schema()));
192    }
193    let payload = resolve_payload::<RevisionStagePayload>(flags, payload)?;
194    let env_id = parse_env_id(&payload.environment_id)?;
195    let deployment_id = parse_deployment_id(&payload.deployment_id)?;
196    // Pre-parse the pack list outside the lock so a payload error doesn't
197    // hold the flock. Only the legacy (no-`bundle_path`) path consumes it; on
198    // the bundle path the lock is derived from the artifact, so skip parsing
199    // entirely — a stale/invalid `pack_list` in an answers payload must not
200    // spuriously fail a `--bundle` stage that ignores it.
201    let pack_list = if payload.bundle_path.is_some() {
202        Vec::new()
203    } else {
204        parse_pack_list(payload.pack_list)?
205    };
206    if !is_valid_transition(RevisionLifecycle::Inactive, RevisionLifecycle::Staged) {
207        return Err(OpError::Conflict(
208            "spec rejects inactive → staged".to_string(),
209        ));
210    }
211    let ctx = AuditCtx {
212        env_id: env_id.clone(),
213        noun: NOUN,
214        verb: "stage",
215        target: json!({
216            "deployment_id": deployment_id.to_string(),
217            "lifecycle_to": "staged",
218        }),
219        idempotency_key: None,
220    };
221    let RevisionStagePayload {
222        bundle_path,
223        bundle_digest: payload_bundle_digest,
224        bundle_source_uri,
225        pack_list_lock_ref: payload_pack_list_lock_ref,
226        config_digest,
227        signature_sidecar_ref,
228        drain_seconds,
229        ..
230    } = payload;
231    audit_and_record(store, ctx, |_committed| {
232        // Look up the deployment INSIDE the authz gate. Touching the
233        // filesystem (bundle extraction, pack-config materialization)
234        // before `audit_and_record` enters its closure would let a
235        // denied caller write under `<env>/revisions/...` before being
236        // rejected — Codex review on PR-3a.5 flagged that authz bypass.
237        let env = store.load(&env_id).map_err(map_store_err_preserving_noun)?;
238        let bundle_id = env
239            .bundles
240            .iter()
241            .find(|b| b.deployment_id == deployment_id)
242            .map(|b| b.bundle_id.clone())
243            .ok_or_else(|| {
244                OpError::NotFound(format!(
245                    "deployment `{deployment_id}` not found in env `{env_id}`"
246                ))
247            })?;
248
249        // Mint the revision id now: the `--bundle` path names the
250        // per-revision extract dir after this ULID, and the pack-list
251        // lock + per-pack pack-config docs are written under that dir
252        // before the typed verb sees them.
253        let revision_id = crate::environment::mint_revision_id();
254        let env_dir = store.env_dir(&env_id)?;
255        // Closure that drops the rev_dir on any post-staging failure
256        // (materialize_pack_configs OR stage_revision). Both call sites
257        // need the same path-join + best-effort remove, so build it once.
258        let drop_rev_dir = || {
259            let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
260            let _ = std::fs::remove_dir_all(&rev_dir);
261        };
262
263        // Resolve a local `.gtbundle` (extract + pin packs) when one
264        // was supplied, deriving the artifact pointers; otherwise
265        // record the caller-supplied pointers verbatim (legacy
266        // Phase-A behavior).
267        let has_bundle = bundle_path.is_some();
268        let (bundle_digest, revision_pack_list, pack_list_lock_ref, pack_config_refs) =
269            match bundle_path {
270                Some(bundle_path) => {
271                    let staged = super::bundle_stage::stage_local_bundle(
272                        &env_dir,
273                        revision_id,
274                        &bundle_path,
275                    )?;
276                    // Walk `staged.lock.packs` once: build both
277                    // `lock_derived_pack_list` (feeds `Revision.pack_list`
278                    // so `Environment::validate`'s config-overrides
279                    // cross-ref has data) and the pinned-pack-id set for
280                    // `materialize_pack_configs` in one pass.
281                    let mut lock_derived_pack_list: Vec<PackListEntry> =
282                        Vec::with_capacity(staged.lock.packs.len());
283                    let mut pinned_pack_ids: std::collections::HashSet<String> =
284                        std::collections::HashSet::with_capacity(staged.lock.packs.len());
285                    for lp in &staged.lock.packs {
286                        let pack_id = lp.pack_id.clone();
287                        pinned_pack_ids.insert(pack_id.as_str().to_string());
288                        lock_derived_pack_list.push(PackListEntry::from_lock_primitives(
289                            pack_id,
290                            lp.digest.clone(),
291                        ));
292                    }
293                    let rev_dir = env_dir.join("revisions").join(revision_id.to_string());
294                    // If pack-config materialization fails AFTER
295                    // `stage_local_bundle` succeeded, drop the rev_dir
296                    // so a re-stage starts clean.
297                    let pack_config_refs = super::pack_config_stage::materialize_pack_configs(
298                        &env_dir,
299                        &rev_dir,
300                        revision_id,
301                        &env_id,
302                        &bundle_id,
303                        &pinned_pack_ids,
304                    )
305                    .inspect_err(|_| drop_rev_dir())?;
306                    (
307                        staged.bundle_digest,
308                        lock_derived_pack_list,
309                        staged.pack_list_lock_ref,
310                        pack_config_refs,
311                    )
312                }
313                None => (
314                    payload_bundle_digest,
315                    pack_list,
316                    payload_pack_list_lock_ref,
317                    Vec::new(),
318                ),
319            };
320
321        let store_payload = StageRevisionPayload {
322            revision_id,
323            deployment_id,
324            bundle_digest,
325            bundle_source_uri,
326            pack_list: revision_pack_list,
327            pack_list_lock_ref,
328            pack_config_refs,
329            config_digest,
330            signature_sidecar_ref,
331            drain_seconds,
332        };
333        // Post-staging cleanup: if the typed verb fails after the
334        // `--bundle` path already wrote files under `rev_dir`, drop
335        // the rev_dir so a re-stage starts clean. Closes a window
336        // that existed pre-PR too: the old closure could return Err
337        // from `locked.save(&env)` with the rev_dir already populated.
338        let revision = store
339            .stage_revision(&env_id, store_payload, mint_idempotency_key())
340            .inspect_err(|_| {
341                if has_bundle {
342                    drop_rev_dir();
343                }
344            })
345            .map_err(map_store_err_preserving_noun)?;
346        let outcome = OpOutcome::new(
347            NOUN,
348            "stage",
349            serde_json::to_value(RevisionSummary::from(&revision))
350                .expect("RevisionSummary is json-safe"),
351        );
352        Ok((outcome, super::AuditGens::NONE))
353    })
354}
355
356/// `op revisions warm`. `staged → warming → ready`. The two-step move is
357/// collapsed here for A3 because no async warm hooks exist yet; Phase D wires
358/// the runner warm API.
359///
360/// Default warm path runs a **Noop** health gate so existing CLI callers stay
361/// behavior-compatible. Producers in higher-tier crates (e.g.
362/// `greentic-start`) wire a real B9 warm/ready gate via
363/// [`warm_with_health_gate`].
364pub fn warm(
365    store: &LocalFsStore,
366    flags: &OpFlags,
367    payload: Option<RevisionTransitionPayload>,
368) -> Result<OpOutcome, OpError> {
369    warm_with_health_gate(store, flags, payload, |_env, _revision| Ok(()))
370}
371
372/// Gate-aware variant of [`warm`] (B9 of `plans/next-gen-deployment.md`).
373///
374/// Drives the same `staged → warming → ready` chain but runs `health_gate`
375/// against a synthesized post-chain `(env, revision)` view. On gate
376/// rejection, the revision is persisted in `Failed` and a `Conflict`
377/// (warm/ready health gate) is surfaced — see
378/// [`crate::environment::apply_revision_transition_with_health_gate`].
379///
380/// **Closure→typed-verb bridge (PR-3a.6b).** The public signature stays
381/// closure-based so external consumers (`greentic-start`) keep their
382/// current contract. Internally the closure is evaluated OUTSIDE the typed
383/// verb's flock:
384///
385/// 1. Load env, find the revision, capture its current lifecycle.
386/// 2. Build a synthesized post-chain `Revision` view (clone with
387///    `lifecycle = Ready`, `warmed_at = None`).
388/// 3. Run the closure against `(env, synthesized_revision)`.
389/// 4. Call `store.warm_revision(env_id, WarmRevisionPayload {
390///    expected_lifecycle: current_lifecycle, health_gate, … })`.
391///
392/// `expected_lifecycle` is the precondition that guards against a
393/// concurrent mutation landing between gate-eval (step 3) and the typed
394/// verb's flock acquisition (step 4) — the verb rejects with `Conflict`
395/// if the revision's lifecycle drifted.
396///
397/// Higher-tier consumers construct the gate from concrete validators
398/// (route-table validate, runtime-config load, signature verify, provider
399/// probes); this function only evaluates the closure and ships the
400/// pre-evaluated outcome, keeping `greentic-deployer` free of any
401/// health-check producers.
402pub fn warm_with_health_gate<G>(
403    store: &LocalFsStore,
404    flags: &OpFlags,
405    payload: Option<RevisionTransitionPayload>,
406    health_gate: G,
407) -> Result<OpOutcome, OpError>
408where
409    G: FnOnce(
410        &greentic_deploy_spec::Environment,
411        &Revision,
412    ) -> Result<(), crate::environment::HealthGateFailure>,
413{
414    if flags.schema_only {
415        return Ok(OpOutcome::new(NOUN, "warm", transition_schema()));
416    }
417    let payload = resolve_payload::<RevisionTransitionPayload>(flags, payload)?;
418    let env_id = parse_env_id(&payload.environment_id)?;
419    let revision_id = parse_revision_id(&payload.revision_id)?;
420    let idempotency_key = super::resolve_idempotency_key(payload.idempotency_key)?;
421
422    // --- Pre-evaluate the health gate outside the typed verb's flock. ---
423    //
424    // Load env + find revision to capture the current lifecycle (the
425    // precondition) and build a synthesized post-chain view for the gate.
426    // A load/find failure here is a pre-flight error that does NOT hold
427    // the env flock — it simply surfaces as NotFound before the audit
428    // boundary.
429    let env = store.load(&env_id)?;
430    let current_revision = env
431        .revisions
432        .iter()
433        .find(|r| r.revision_id == revision_id)
434        .ok_or_else(|| {
435            OpError::NotFound(format!(
436                "revision `{revision_id}` not found in env `{env_id}`"
437            ))
438        })?;
439    let current_lifecycle = current_revision.lifecycle;
440
441    // Synthesize the post-chain revision view the gate will inspect: the
442    // lifecycle helper would walk `Staged → Warming → Ready` and stamp
443    // `warmed_at`, so the gate sees `Ready` with no warmed_at (the real
444    // stamp lands inside the typed verb AFTER the gate passes).
445    let mut synthesized = current_revision.clone();
446    synthesized.lifecycle = RevisionLifecycle::Ready;
447    // `warmed_at` is left as-is (None for a first warm; re-stamped on
448    // idempotent retry by the typed verb).
449
450    let gate_result = health_gate(&env, &synthesized);
451
452    // --- Dispatch to the typed verb through the standard adapter. ---
453    let op = "warm";
454    let ctx = AuditCtx {
455        env_id: env_id.clone(),
456        noun: NOUN,
457        verb: op,
458        target: json!({
459            "revision_id": revision_id.to_string(),
460            "lifecycle_to": RevisionLifecycle::Ready,
461        }),
462        idempotency_key: Some(idempotency_key.as_str().to_string()),
463    };
464    audit_and_record(store, ctx, |committed| {
465        let store_result = store
466            .warm_revision(
467                &env_id,
468                WarmRevisionPayload {
469                    revision_id,
470                    health_gate: gate_result,
471                    expected_lifecycle: current_lifecycle,
472                },
473                idempotency_key,
474            )
475            .inspect_err(|err| {
476                if err.is_committed_after_save() {
477                    committed.mark_committed();
478                }
479            });
480        // The warm typed verb's HealthGateFailed path persists Failed
481        // state, so mirror the closure-based committed-on-error contract.
482        match &store_result {
483            Err(crate::environment::StoreError::Lifecycle(inner))
484                if matches!(
485                    inner.as_ref(),
486                    crate::environment::LifecycleError::HealthGateFailed { .. }
487                ) =>
488            {
489                committed.mark_committed();
490                // Best-effort gate-failed emit: load the post-save env
491                // so the emit sees the Failed lifecycle.
492                if let Ok(env_for_emit) = store.load(&env_id)
493                    && let Some(rev_for_emit) = env_for_emit
494                        .revisions
495                        .iter()
496                        .find(|r| r.revision_id == revision_id)
497                {
498                    emit_for_op(op, true, None, &env_for_emit, rev_for_emit);
499                }
500                return Err(map_store_err_preserving_noun(store_result.unwrap_err()));
501            }
502            _ => {}
503        }
504        let outcome = store_result.map_err(map_store_err_preserving_noun)?;
505        committed.mark_committed();
506        emit_for_op(
507            op,
508            false,
509            Some(outcome.starting_lifecycle),
510            &outcome.environment,
511            &outcome.revision,
512        );
513        let summary = RevisionSummary::from(&outcome.revision);
514        let op_outcome = OpOutcome::new(
515            NOUN,
516            op,
517            serde_json::to_value(summary).expect("RevisionSummary is json-safe"),
518        );
519        Ok((op_outcome, super::AuditGens::NONE))
520    })
521}
522
523/// `op revisions drain`. `ready → draining`. The full in-flight drain dance
524/// (sessions, WebSocket cleanup, etc.) lives in the runtime; this command
525/// records the intent and stamps the lifecycle.
526pub fn drain(
527    store: &LocalFsStore,
528    flags: &OpFlags,
529    payload: Option<RevisionTransitionPayload>,
530) -> Result<OpOutcome, OpError> {
531    if flags.schema_only {
532        return Ok(OpOutcome::new(NOUN, "drain", transition_schema()));
533    }
534    typed_transition(
535        store,
536        flags,
537        payload,
538        "drain",
539        RevisionLifecycle::Draining,
540        |env_id, revision_id, key| store.drain_revision(env_id, revision_id, key),
541    )
542}
543
544/// `op revisions archive`. Transitions the lifecycle to `archived` and
545/// removes the revision from `BundleDeployment.current_revisions`. Refuses
546/// archival of any revision still referenced by a live `TrafficSplit` —
547/// callers must rebalance traffic through `gtc op traffic set` first.
548///
549/// Accepts the full retirement walk: `Staged | Warming | Ready | Failed`
550/// archive in one hop; a revision already drained (Draining → Inactive
551/// via the runtime) completes through `Inactive → Archived` in the same
552/// CLI call.
553pub fn archive(
554    store: &LocalFsStore,
555    flags: &OpFlags,
556    payload: Option<RevisionTransitionPayload>,
557) -> Result<OpOutcome, OpError> {
558    if flags.schema_only {
559        return Ok(OpOutcome::new(NOUN, "archive", transition_schema()));
560    }
561    typed_transition(
562        store,
563        flags,
564        payload,
565        "archive",
566        RevisionLifecycle::Archived,
567        |env_id, revision_id, key| store.archive_revision(env_id, revision_id, key),
568    )
569}
570
571/// `op revisions list <env>` (filterable by `--deployment <id>` later).
572pub fn list(
573    store: &dyn EnvironmentReads,
574    flags: &OpFlags,
575    env_id: &str,
576) -> Result<OpOutcome, OpError> {
577    if flags.schema_only {
578        return Ok(OpOutcome::new(
579            NOUN,
580            "list",
581            json!({"input_schema": "env_id positional"}),
582        ));
583    }
584    let env_id = parse_env_id(env_id)?;
585    if !store.env_exists(&env_id)? {
586        return Err(OpError::NotFound(format!("environment `{env_id}`")));
587    }
588    let env = store.load_env(&env_id)?;
589    let revisions: Vec<RevisionSummary> = env.revisions.iter().map(RevisionSummary::from).collect();
590    Ok(OpOutcome::new(
591        NOUN,
592        "list",
593        json!({"environment_id": env_id.as_str(), "revisions": revisions}),
594    ))
595}
596
597// --- internals -----------------------------------------------------------
598
599/// CLI-side adapter over a typed
600/// [`EnvironmentMutations`](crate::environment::EnvironmentMutations) revision
601/// verb. PR-3a.6 replaces the closure-based
602/// `apply_revision_transition` driver for the no-gate path (drain + archive)
603/// — the typed verb method owns the `transact` flock and the
604/// `refresh_runtime_config` refresh; the CLI handles authz, audit, payload
605/// resolution, error noun preservation, and lifecycle-event emission.
606///
607/// PR-3a.6b migrated `warm` to its own typed-verb adapter in
608/// [`warm_with_health_gate`]; this function now serves drain + archive only.
609fn typed_transition<F>(
610    store: &LocalFsStore,
611    flags: &OpFlags,
612    payload: Option<RevisionTransitionPayload>,
613    op: &'static str,
614    lifecycle_to: RevisionLifecycle,
615    call_verb: F,
616) -> Result<OpOutcome, OpError>
617where
618    F: FnOnce(
619        &greentic_deploy_spec::EnvId,
620        greentic_deploy_spec::RevisionId,
621        greentic_deploy_spec::IdempotencyKey,
622    ) -> Result<
623        crate::environment::RevisionTransitionOutcome,
624        crate::environment::StoreError,
625    >,
626{
627    let payload = resolve_payload::<RevisionTransitionPayload>(flags, payload)?;
628    let env_id = parse_env_id(&payload.environment_id)?;
629    let revision_id = parse_revision_id(&payload.revision_id)?;
630    // Resolve the idempotency key once — same value lands in the audit
631    // event and in the typed verb call so an HTTP backend (PR-3b) can
632    // replay the original outcome on a lost-response retry. Falling back
633    // to a fresh ULID keeps existing CLI usage working unchanged.
634    let idempotency_key = super::resolve_idempotency_key(payload.idempotency_key)?;
635    let ctx = AuditCtx {
636        env_id: env_id.clone(),
637        noun: NOUN,
638        verb: op,
639        // Both drain (always `Draining`) and archive (always `Archived`
640        // — the `Draining → Inactive → Archived` chain walks end-to-end
641        // in one call so a successful archive start always lands on
642        // `Archived`) are deterministic, so record the verb-target state.
643        target: json!({
644            "revision_id": revision_id.to_string(),
645            "lifecycle_to": lifecycle_to,
646        }),
647        idempotency_key: Some(idempotency_key.as_str().to_string()),
648    };
649    audit_and_record(store, ctx, |committed| {
650        // Committed-on-error: when the typed verb's lifecycle helper
651        // saved the env mutation but a post-save step (load /
652        // refresh_runtime_config) failed, mark committed BEFORE the
653        // error escapes so the audit boundary fails-closed on an
654        // audit-append failure (see
655        // `warm_ok_with_refresh_failure_and_audit_failure_returns_audit_error`).
656        let outcome = call_verb(&env_id, revision_id, idempotency_key)
657            .inspect_err(|err| {
658                if err.is_committed_after_save() {
659                    committed.mark_committed();
660                }
661            })
662            .map_err(map_store_err_preserving_noun)?;
663        // Typed-verb Ok = saved + runtime-config refreshed before return,
664        // so mark committed before any best-effort emit unwinds.
665        committed.mark_committed();
666        emit_for_op(
667            op,
668            false,
669            Some(outcome.starting_lifecycle),
670            &outcome.environment,
671            &outcome.revision,
672        );
673        let summary = RevisionSummary::from(&outcome.revision);
674        let op_outcome = OpOutcome::new(
675            NOUN,
676            op,
677            serde_json::to_value(summary).expect("RevisionSummary is json-safe"),
678        );
679        Ok((op_outcome, super::AuditGens::NONE))
680    })
681}
682
683/// Verb → [`RolloutEvent`] dispatcher (C5.3).
684///
685/// Centralizes which event(s) each lifecycle verb emits on a successful or
686/// health-gate-failed transition, so the verb mapping lives in one place
687/// rather than scattered across [`warm`] / [`drain`] / [`archive`] /
688/// [`decommission`] / [`activate`].
689///
690/// - `warm` Ok → `HealthGatePassed` + `RevisionWarmed` (the warm verb both
691///   passes the gate and lands the revision in `Ready`).
692/// - `warm` health-gate fail → `HealthGateFailed`.
693/// - `drain` Ok → `RevisionDraining`.
694/// - `archive` Ok with `starting_lifecycle == Some(Draining)` →
695///   `RevisionEvicted` (the post-drain eviction hop). The final lifecycle
696///   alone can't discriminate this: the `archive` chain walks
697///   `Draining → Inactive → Archived` end-to-end in one call, so the
698///   revision lands on `Archived` regardless of where it started. We key on
699///   the starting lifecycle so a `Ready → Archived` archive (lifecycle
700///   retirement, NOT a rollout eviction) correctly emits nothing.
701/// - Other verbs and chains: no emit (no live rollout-event match).
702pub(crate) fn emit_for_op(
703    op: &'static str,
704    gate_failed: bool,
705    starting_lifecycle: Option<RevisionLifecycle>,
706    env: &Environment,
707    revision: &Revision,
708) {
709    match (op, gate_failed) {
710        ("warm", false) => {
711            emit_lifecycle_event(RolloutEvent::HealthGatePassed, env, revision);
712            emit_lifecycle_event(RolloutEvent::RevisionWarmed, env, revision);
713        }
714        ("warm", true) => {
715            emit_lifecycle_event(RolloutEvent::HealthGateFailed, env, revision);
716        }
717        ("drain", false) => {
718            emit_lifecycle_event(RolloutEvent::RevisionDraining, env, revision);
719        }
720        ("archive", false) if starting_lifecycle == Some(RevisionLifecycle::Draining) => {
721            emit_lifecycle_event(RolloutEvent::RevisionEvicted, env, revision);
722        }
723        _ => {}
724    }
725}
726
727/// Build a [`RevisionStagePayload`] from direct CLI args, or `None` when no
728/// positional args were supplied (deferring to `--answers` / `--schema`).
729/// Mirrors `traffic::payload_from_set_args`: all clap fields are optional so
730/// the answers/schema paths keep working unchanged.
731pub fn payload_from_stage_args(
732    args: super::dispatch::RevisionStageArgs,
733) -> Result<Option<RevisionStagePayload>, OpError> {
734    let super::dispatch::RevisionStageArgs {
735        env_id,
736        deployment,
737        bundle,
738    } = args;
739    // Nothing positional → answers/schema path.
740    if env_id.is_none() && deployment.is_none() && bundle.is_none() {
741        return Ok(None);
742    }
743    let environment_id = env_id.ok_or_else(|| {
744        OpError::InvalidArgument("revisions stage: missing positional `<env_id>`".to_string())
745    })?;
746    let deployment_id = deployment.ok_or_else(|| {
747        OpError::InvalidArgument("revisions stage: missing `--deployment <ULID>`".to_string())
748    })?;
749    // Require `--bundle` on the direct path: without it we'd stage a revision
750    // with a placeholder digest and a `pack_list_lock_ref` pointing at a lock
751    // file that was never written — warmable by the no-op gate, admissible by
752    // traffic, and broken at boot. The legacy verbatim path stays reachable
753    // only via an explicit `--answers <file>`.
754    let bundle_path = bundle.ok_or_else(|| {
755        OpError::InvalidArgument(
756            "revisions stage: missing `--bundle <PATH>`. The direct CLI path stages a local \
757             .gtbundle; use `--answers <file>` for the legacy verbatim path."
758                .to_string(),
759        )
760    })?;
761    Ok(Some(RevisionStagePayload {
762        environment_id,
763        deployment_id,
764        // Direct local `--bundle` stage: the store mints the id + key.
765        revision_id: None,
766        idempotency_key: None,
767        bundle_path: Some(bundle_path),
768        bundle_digest: default_bundle_digest(),
769        // Direct CLI staging takes a local `.gtbundle`; there is no registry
770        // coordinate to record, so the revision is local-serve only. Pass
771        // `bundle_source_uri` via `--answers` to make it remotely pullable.
772        bundle_source_uri: None,
773        pack_list: Vec::new(),
774        pack_list_lock_ref: PathBuf::new(),
775        config_digest: default_config_digest(),
776        signature_sidecar_ref: default_signature_sidecar_ref(),
777        drain_seconds: default_drain_seconds(),
778    }))
779}
780
781fn resolve_payload<T: serde::de::DeserializeOwned>(
782    flags: &OpFlags,
783    payload: Option<T>,
784) -> Result<T, OpError> {
785    if let Some(p) = payload {
786        return Ok(p);
787    }
788    if let Some(path) = &flags.answers {
789        return super::load_answers::<T>(path);
790    }
791    Err(OpError::InvalidArgument(
792        "no payload provided: pass --answers <path> or supply the payload directly".to_string(),
793    ))
794}
795
796fn parse_env_id(raw: &str) -> Result<EnvId, OpError> {
797    EnvId::try_from(raw).map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))
798}
799
800fn parse_deployment_id(raw: &str) -> Result<DeploymentId, OpError> {
801    use std::str::FromStr;
802    let ulid = ulid::Ulid::from_str(raw)
803        .map_err(|e| OpError::InvalidArgument(format!("deployment_id: {e}")))?;
804    Ok(DeploymentId(ulid))
805}
806
807fn parse_revision_id(raw: &str) -> Result<RevisionId, OpError> {
808    use std::str::FromStr;
809    let ulid = ulid::Ulid::from_str(raw)
810        .map_err(|e| OpError::InvalidArgument(format!("revision_id: {e}")))?;
811    Ok(RevisionId(ulid))
812}
813
814#[allow(dead_code)]
815fn discard_bundle(_id: &BundleId) {
816    // bundle_id is never used after derivation; this helper keeps the type
817    // around for future planning hooks (e.g. ensuring stage rejects a
818    // revision whose payload bundle_id contradicts the deployment's).
819}
820
821fn stage_schema() -> Value {
822    json!({
823        "$schema": "https://json-schema.org/draft/2020-12/schema",
824        "title": "RevisionStagePayload",
825        "type": "object",
826        "required": ["environment_id", "deployment_id"],
827        "additionalProperties": false,
828        "properties": {
829            "environment_id": {"type": "string"},
830            "deployment_id": {"type": "string", "description": "ULID"},
831            "bundle_path": {"type": "string", "description": "Local .gtbundle to extract + pin; derives bundle_digest/pack_list/pack_list_lock_ref"},
832            "bundle_digest": {"type": "string"},
833            "bundle_source_uri": {"type": "string", "description": "oci:// / repo:// / store:// ref the bundle was resolved from; makes the revision pullable by a remote worker. Omit for local-serve-only"},
834            "pack_list": {"type": "array"},
835            "pack_list_lock_ref": {"type": "string"},
836            "config_digest": {"type": "string"},
837            "signature_sidecar_ref": {"type": "string"},
838            "drain_seconds": {"type": "integer", "minimum": 0}
839        }
840    })
841}
842
843fn transition_schema() -> Value {
844    json!({
845        "$schema": "https://json-schema.org/draft/2020-12/schema",
846        "title": "RevisionTransitionPayload",
847        "type": "object",
848        "required": ["environment_id", "revision_id"],
849        "additionalProperties": false,
850        "properties": {
851            "environment_id": {"type": "string"},
852            "revision_id": {"type": "string", "description": "ULID"},
853            "idempotency_key": {
854                "type": "string",
855                "description": "Optional A8 §2 caller-supplied key for safe retry replay; minted per-invocation when omitted."
856            }
857        }
858    })
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::cli::tests_common::{make_bundle_deployment, make_env};
865    use tempfile::tempdir;
866
867    /// PR-3a.7 schema-drift regression (carries the same fix as
868    /// `cli::bundles::tests::remove_schema_lists_idempotency_key`):
869    /// `RevisionTransitionPayload` accepts an `idempotency_key` field, so
870    /// `--schema` output MUST list it under `properties` — otherwise
871    /// schema-driven callers reject the exact field needed for A8 §2
872    /// retry replay.
873    #[test]
874    fn transition_schema_lists_idempotency_key() {
875        let schema = transition_schema();
876        assert!(
877            schema.pointer("/properties/idempotency_key").is_some(),
878            "transition_schema must list `idempotency_key` so --schema-driven \
879             callers can supply the A8 retry key (schema: {schema:#})"
880        );
881    }
882
883    /// Schema-drift regression: `stage_schema()` declares
884    /// `additionalProperties: false`, so a `--schema`-driven `--answers` caller
885    /// that supplies `bundle_source_uri` (the remote-pull coordinate) would be
886    /// rejected unless the schema advertises the field. Without this the env
887    /// store records a non-pullable revision.
888    #[test]
889    fn stage_schema_lists_bundle_source_uri() {
890        let schema = stage_schema();
891        assert!(
892            schema.pointer("/properties/bundle_source_uri").is_some(),
893            "stage_schema must list `bundle_source_uri` so --schema-driven \
894             callers can record the bundle's registry source (schema: {schema:#})"
895        );
896    }
897
898    fn seed_env_with_deployment(store: &LocalFsStore) -> DeploymentId {
899        let mut env = make_env("local");
900        let deployment = make_bundle_deployment("local", "fast2flow");
901        let did = deployment.deployment_id;
902        env.bundles.push(deployment);
903        store.save(&env).unwrap();
904        did
905    }
906
907    fn stage_payload(deployment_id: &DeploymentId) -> RevisionStagePayload {
908        RevisionStagePayload {
909            environment_id: "local".to_string(),
910            deployment_id: deployment_id.to_string(),
911            revision_id: None,
912            idempotency_key: None,
913            bundle_path: None,
914            bundle_digest: "sha256:00".to_string(),
915            bundle_source_uri: None,
916            pack_list: vec![PackListEntryPayload {
917                pack_id: "greentic.test.pack".to_string(),
918                version: "1.0.0".to_string(),
919                digest: "sha256:00".to_string(),
920                source_uri: None,
921            }],
922            pack_list_lock_ref: PathBuf::new(),
923            config_digest: default_config_digest(),
924            signature_sidecar_ref: default_signature_sidecar_ref(),
925            drain_seconds: default_drain_seconds(),
926        }
927    }
928
929    #[test]
930    fn stage_creates_revision_in_staged() {
931        let dir = tempdir().unwrap();
932        let store = LocalFsStore::new(dir.path());
933        let did = seed_env_with_deployment(&store);
934        let outcome = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
935        assert_eq!(
936            outcome.result.get("lifecycle").and_then(|v| v.as_str()),
937            Some("staged")
938        );
939        assert_eq!(
940            outcome.result.get("sequence").and_then(|v| v.as_u64()),
941            Some(1)
942        );
943    }
944
945    /// A stage payload that asserts the bundle's registry source records
946    /// `bundle_source_uri` on the stored revision (threaded
947    /// `RevisionStagePayload` → `StageRevisionPayload` → `Revision`), so a
948    /// remote worker can pull the bundle at boot. The default local stage
949    /// leaves it `None`.
950    #[test]
951    fn stage_records_bundle_source_uri_on_the_revision() {
952        let dir = tempdir().unwrap();
953        let store = LocalFsStore::new(dir.path());
954        let did = seed_env_with_deployment(&store);
955
956        let mut payload = stage_payload(&did);
957        payload.bundle_source_uri =
958            Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc".to_string());
959        stage(&store, &OpFlags::default(), Some(payload)).unwrap();
960
961        let env = store.load(&parse_env_id("local").unwrap()).unwrap();
962        assert_eq!(
963            env.revisions[0].bundle_source_uri.as_deref(),
964            Some("oci://ghcr.io/greenticai/bundles/demo@sha256:abc")
965        );
966    }
967
968    /// Named (non-`local`) envs are first-class on the local store, so staging
969    /// a bundle into a named env is no longer authorization-denied. (The old
970    /// "no FS writes before the deny" invariant is moot locally — there is no
971    /// local deny path — and the remote RBAC path enforces it server-side.)
972    #[test]
973    fn stage_with_bundle_on_named_env_is_not_authz_denied() {
974        let dir = tempdir().unwrap();
975        let store = LocalFsStore::new(dir.path());
976        let mut env = make_env("prod");
977        let deployment = make_bundle_deployment("prod", "fast2flow");
978        let did = deployment.deployment_id;
979        env.bundles.push(deployment);
980        store.save(&env).unwrap();
981
982        let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
983            .join("testdata/bundles/perf-smoke-bundle.gtbundle");
984        let mut payload = stage_payload(&did);
985        payload.environment_id = "prod".to_string();
986        payload.bundle_path = Some(fixture);
987
988        let result = stage(&store, &OpFlags::default(), Some(payload));
989        assert!(
990            !matches!(result, Err(OpError::Unauthorized { .. })),
991            "named env stage must not be authz-denied; got {result:?}"
992        );
993    }
994
995    #[test]
996    fn stage_with_local_bundle_pins_packs_into_lockfile() {
997        use greentic_deploy_spec::PackListLock;
998        use sha2::{Digest, Sha256};
999
1000        let dir = tempdir().unwrap();
1001        let store = LocalFsStore::new(dir.path());
1002        let did = seed_env_with_deployment(&store);
1003
1004        let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1005            .join("testdata/bundles/perf-smoke-bundle.gtbundle");
1006        let mut payload = stage_payload(&did);
1007        payload.bundle_path = Some(fixture);
1008        // Caller-supplied pack pointers must be ignored on the bundle path.
1009        payload.pack_list = vec![PackListEntryPayload {
1010            pack_id: "should.be.ignored".to_string(),
1011            version: "9.9.9".to_string(),
1012            digest: "sha256:ff".to_string(),
1013            source_uri: None,
1014        }];
1015
1016        let outcome = stage(&store, &OpFlags::default(), Some(payload)).unwrap();
1017        assert_eq!(
1018            outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1019            Some("staged")
1020        );
1021        let rid = outcome
1022            .result
1023            .get("revision_id")
1024            .and_then(|v| v.as_str())
1025            .unwrap()
1026            .to_string();
1027
1028        // The stored revision points at the derived lock + a real bundle digest.
1029        let env_id = EnvId::try_from("local").unwrap();
1030        let env = store.load(&env_id).unwrap();
1031        let revision = env
1032            .revisions
1033            .iter()
1034            .find(|r| r.revision_id.to_string() == rid)
1035            .expect("revision persisted");
1036        assert!(
1037            revision.bundle_digest.starts_with("sha256:") && revision.bundle_digest != "sha256:00",
1038            "bundle_digest should be the real archive hash, got {}",
1039            revision.bundle_digest
1040        );
1041        // Inline pack_list is populated from the lock's pack ids so
1042        // Environment::validate's config_overrides cross-ref works
1043        // (Codex finding 1 fix). The lock file stays the on-disk source
1044        // of truth; the inline list carries pack_id membership only.
1045        assert!(
1046            !revision.pack_list.is_empty(),
1047            "pack_list should be populated from the lock"
1048        );
1049
1050        let env_dir = store.env_dir(&env_id).unwrap();
1051        let lock_path = env_dir.join(&revision.pack_list_lock_ref);
1052        assert!(lock_path.is_file(), "pack-list.lock must be a regular file");
1053
1054        let lock: PackListLock =
1055            serde_json::from_slice(&std::fs::read(&lock_path).unwrap()).unwrap();
1056        assert_eq!(lock.revision_id, revision.revision_id);
1057        assert!(!lock.packs.is_empty(), "fixture bundle has a .gtpack");
1058
1059        for pack in &lock.packs {
1060            // Ref is env-relative and resolves under the env dir to a real file.
1061            assert!(pack.path.is_relative(), "lock path must be env-relative");
1062            let pack_path = env_dir.join(&pack.path);
1063            assert!(
1064                pack_path.is_file(),
1065                "extracted .gtpack must exist: {}",
1066                pack_path.display()
1067            );
1068            // The pinned digest equals the on-disk file's sha256.
1069            let bytes = std::fs::read(&pack_path).unwrap();
1070            let expected = format!("sha256:{}", hex::encode(Sha256::digest(&bytes)));
1071            assert_eq!(pack.digest, expected, "lock digest must match the file");
1072        }
1073    }
1074
1075    /// The direct CLI path must reject a stage with env+deployment but no
1076    /// `--bundle` — otherwise it would create a placeholder revision pointing
1077    /// at a never-written lock file (Codex finding 1).
1078    #[test]
1079    fn stage_args_without_bundle_is_rejected() {
1080        let did = DeploymentId::new();
1081        let args = crate::cli::dispatch::RevisionStageArgs {
1082            env_id: Some("local".to_string()),
1083            deployment: Some(did.to_string()),
1084            bundle: None,
1085        };
1086        let err = payload_from_stage_args(args).unwrap_err();
1087        let msg = format!("{err}");
1088        assert!(
1089            matches!(err, OpError::InvalidArgument(_)) && msg.contains("--bundle"),
1090            "expected a missing --bundle error, got: {msg}"
1091        );
1092    }
1093
1094    /// No positional args at all → defer to `--answers` (returns `None`), so
1095    /// the legacy path stays reachable.
1096    #[test]
1097    fn stage_args_empty_defers_to_answers() {
1098        let args = crate::cli::dispatch::RevisionStageArgs {
1099            env_id: None,
1100            deployment: None,
1101            bundle: None,
1102        };
1103        assert!(payload_from_stage_args(args).unwrap().is_none());
1104    }
1105
1106    /// The recorded `bundle_digest` is bound to the immutable staged copy under
1107    /// the revision dir, not to the (mutable) input path: mutating the original
1108    /// after staging must not change what was pinned (Codex finding 2).
1109    #[test]
1110    fn stage_bundle_digest_is_bound_to_staged_copy_not_input() {
1111        use sha2::{Digest, Sha256};
1112
1113        let dir = tempdir().unwrap();
1114        let store = LocalFsStore::new(dir.path());
1115        let did = seed_env_with_deployment(&store);
1116
1117        // Stage from a temp copy of the fixture so we can mutate "the input"
1118        // afterward without touching the committed fixture.
1119        let fixture = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
1120            .join("testdata/bundles/perf-smoke-bundle.gtbundle");
1121        let input = dir.path().join("input.gtbundle");
1122        std::fs::copy(&fixture, &input).unwrap();
1123
1124        let mut payload = stage_payload(&did);
1125        payload.bundle_path = Some(input.clone());
1126        let outcome = stage(&store, &OpFlags::default(), Some(payload)).unwrap();
1127        let rid = outcome
1128            .result
1129            .get("revision_id")
1130            .and_then(|v| v.as_str())
1131            .unwrap()
1132            .to_string();
1133
1134        let env_id = EnvId::try_from("local").unwrap();
1135        let env = store.load(&env_id).unwrap();
1136        let revision = env
1137            .revisions
1138            .iter()
1139            .find(|r| r.revision_id.to_string() == rid)
1140            .unwrap();
1141
1142        // The staged copy exists and its sha256 equals the recorded digest.
1143        let env_dir = store.env_dir(&env_id).unwrap();
1144        let staged = env_dir.join("revisions").join(&rid).join("bundle.gtbundle");
1145        assert!(staged.is_file(), "staged bundle copy must persist");
1146        let staged_digest = format!(
1147            "sha256:{}",
1148            hex::encode(Sha256::digest(std::fs::read(&staged).unwrap()))
1149        );
1150        assert_eq!(revision.bundle_digest, staged_digest);
1151
1152        // Corrupt the original input; the staged copy + recorded digest are
1153        // unaffected (the digest is over bytes we control, not the input path).
1154        std::fs::write(&input, b"tampered-after-stage").unwrap();
1155        let staged_digest_after = format!(
1156            "sha256:{}",
1157            hex::encode(Sha256::digest(std::fs::read(&staged).unwrap()))
1158        );
1159        assert_eq!(
1160            revision.bundle_digest, staged_digest_after,
1161            "input mutation must not change the staged artifact's digest"
1162        );
1163    }
1164
1165    #[test]
1166    fn warm_advances_to_ready() {
1167        let dir = tempdir().unwrap();
1168        let store = LocalFsStore::new(dir.path());
1169        let did = seed_env_with_deployment(&store);
1170        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1171        let rid = staged
1172            .result
1173            .get("revision_id")
1174            .and_then(|v| v.as_str())
1175            .unwrap()
1176            .to_string();
1177        let warmed = warm(
1178            &store,
1179            &OpFlags::default(),
1180            Some(RevisionTransitionPayload {
1181                environment_id: "local".to_string(),
1182                revision_id: rid,
1183                idempotency_key: None,
1184            }),
1185        )
1186        .unwrap();
1187        assert_eq!(
1188            warmed.result.get("lifecycle").and_then(|v| v.as_str()),
1189            Some("ready")
1190        );
1191    }
1192
1193    #[test]
1194    fn drain_after_warm_succeeds() {
1195        let dir = tempdir().unwrap();
1196        let store = LocalFsStore::new(dir.path());
1197        let did = seed_env_with_deployment(&store);
1198        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1199        let rid = staged
1200            .result
1201            .get("revision_id")
1202            .and_then(|v| v.as_str())
1203            .unwrap()
1204            .to_string();
1205        warm(
1206            &store,
1207            &OpFlags::default(),
1208            Some(RevisionTransitionPayload {
1209                environment_id: "local".to_string(),
1210                revision_id: rid.clone(),
1211                idempotency_key: None,
1212            }),
1213        )
1214        .unwrap();
1215        let drained = drain(
1216            &store,
1217            &OpFlags::default(),
1218            Some(RevisionTransitionPayload {
1219                environment_id: "local".to_string(),
1220                revision_id: rid,
1221                idempotency_key: None,
1222            }),
1223        )
1224        .unwrap();
1225        assert_eq!(
1226            drained.result.get("lifecycle").and_then(|v| v.as_str()),
1227            Some("draining")
1228        );
1229    }
1230
1231    #[test]
1232    fn drain_from_staged_errors() {
1233        let dir = tempdir().unwrap();
1234        let store = LocalFsStore::new(dir.path());
1235        let did = seed_env_with_deployment(&store);
1236        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1237        let rid = staged
1238            .result
1239            .get("revision_id")
1240            .and_then(|v| v.as_str())
1241            .unwrap()
1242            .to_string();
1243        let err = drain(
1244            &store,
1245            &OpFlags::default(),
1246            Some(RevisionTransitionPayload {
1247                environment_id: "local".to_string(),
1248                revision_id: rid,
1249                idempotency_key: None,
1250            }),
1251        )
1252        .unwrap_err();
1253        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1254    }
1255
1256    #[test]
1257    fn archive_prunes_current_revisions_when_no_live_traffic() {
1258        // After the A5 follow-up landed the active-traffic guard, archive
1259        // no longer silently prunes live splits. This test exercises the
1260        // happy path: revision is in `current_revisions` but NOT in any
1261        // traffic split. Archive succeeds and strips the tracking
1262        // reference; no traffic state is touched.
1263        let dir = tempdir().unwrap();
1264        let store = LocalFsStore::new(dir.path());
1265        let mut env = make_env("local");
1266        let mut deployment = make_bundle_deployment("local", "fast2flow");
1267        let did = deployment.deployment_id;
1268        let revision = crate::cli::tests_common::make_revision(
1269            "local",
1270            "fast2flow",
1271            &did,
1272            1,
1273            RevisionLifecycle::Ready,
1274        );
1275        let rid = revision.revision_id;
1276        deployment.current_revisions.push(rid);
1277        env.bundles.push(deployment);
1278        env.revisions.push(revision);
1279        store.save(&env).unwrap();
1280
1281        let outcome = archive(
1282            &store,
1283            &OpFlags::default(),
1284            Some(RevisionTransitionPayload {
1285                environment_id: "local".to_string(),
1286                revision_id: rid.to_string(),
1287                idempotency_key: None,
1288            }),
1289        )
1290        .unwrap();
1291        assert_eq!(
1292            outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1293            Some("archived")
1294        );
1295
1296        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1297        assert!(
1298            env.bundles[0].current_revisions.is_empty(),
1299            "current_revisions should be pruned"
1300        );
1301    }
1302
1303    #[test]
1304    fn archive_refuses_when_revision_is_in_live_traffic_split() {
1305        // Operator workflow guarantee: archiving a revision that still
1306        // routes live traffic surfaces a Conflict pointing at the splits
1307        // to rebalance. The CLI maps `LifecycleError::ActiveTrafficReference`
1308        // through `From<LifecycleError> for OpError`.
1309        let dir = tempdir().unwrap();
1310        let store = LocalFsStore::new(dir.path());
1311        let mut env = make_env("local");
1312        let mut deployment = make_bundle_deployment("local", "fast2flow");
1313        let did = deployment.deployment_id;
1314        let revision = crate::cli::tests_common::make_revision(
1315            "local",
1316            "fast2flow",
1317            &did,
1318            1,
1319            RevisionLifecycle::Ready,
1320        );
1321        let rid = revision.revision_id;
1322        deployment.current_revisions.push(rid);
1323        let split = crate::cli::tests_common::make_traffic_split(
1324            "local",
1325            "fast2flow",
1326            &did,
1327            &rid,
1328            "test-key",
1329        );
1330        env.bundles.push(deployment);
1331        env.revisions.push(revision);
1332        env.traffic_splits.push(split);
1333        store.save(&env).unwrap();
1334
1335        let err = archive(
1336            &store,
1337            &OpFlags::default(),
1338            Some(RevisionTransitionPayload {
1339                environment_id: "local".to_string(),
1340                revision_id: rid.to_string(),
1341                idempotency_key: None,
1342            }),
1343        )
1344        .unwrap_err();
1345        match err {
1346            OpError::Conflict(msg) => {
1347                assert!(
1348                    msg.contains("live traffic split")
1349                        && msg.contains("rebalance via `gtc op traffic set`"),
1350                    "expected actionable conflict message, got: {msg}"
1351                );
1352            }
1353            other => panic!("expected Conflict, got `{other:?}`"),
1354        }
1355
1356        // Nothing persisted: lifecycle still Ready, split intact.
1357        let env = store.load(&EnvId::try_from("local").unwrap()).unwrap();
1358        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1359        assert_eq!(env.traffic_splits.len(), 1);
1360        assert!(env.bundles[0].current_revisions.contains(&rid));
1361    }
1362
1363    #[test]
1364    fn archive_completes_a_drained_revision_through_inactive() {
1365        // Operator action: `drain` moved Ready → Draining; runtime
1366        // separately moved Draining → Inactive (simulated here via the
1367        // store). Archive walks Inactive → Archived in a single CLI call.
1368        let dir = tempdir().unwrap();
1369        let store = LocalFsStore::new(dir.path());
1370        let mut env = make_env("local");
1371        let deployment = make_bundle_deployment("local", "fast2flow");
1372        let did = deployment.deployment_id;
1373        let revision = crate::cli::tests_common::make_revision(
1374            "local",
1375            "fast2flow",
1376            &did,
1377            1,
1378            RevisionLifecycle::Inactive,
1379        );
1380        let rid = revision.revision_id;
1381        env.bundles.push(deployment);
1382        env.revisions.push(revision);
1383        store.save(&env).unwrap();
1384
1385        let outcome = archive(
1386            &store,
1387            &OpFlags::default(),
1388            Some(RevisionTransitionPayload {
1389                environment_id: "local".to_string(),
1390                revision_id: rid.to_string(),
1391                idempotency_key: None,
1392            }),
1393        )
1394        .unwrap();
1395        assert_eq!(
1396            outcome.result.get("lifecycle").and_then(|v| v.as_str()),
1397            Some("archived")
1398        );
1399    }
1400
1401    #[test]
1402    fn list_reflects_stage_calls() {
1403        let dir = tempdir().unwrap();
1404        let store = LocalFsStore::new(dir.path());
1405        let did = seed_env_with_deployment(&store);
1406        stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1407        stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1408        let listed = list(&store, &OpFlags::default(), "local").unwrap();
1409        let revs = listed
1410            .result
1411            .get("revisions")
1412            .and_then(|v| v.as_array())
1413            .unwrap();
1414        assert_eq!(revs.len(), 2);
1415        // Sequences 1 and 2.
1416        let seqs: Vec<u64> = revs
1417            .iter()
1418            .filter_map(|r| r.get("sequence").and_then(|v| v.as_u64()))
1419            .collect();
1420        assert_eq!(seqs, vec![1, 2]);
1421    }
1422
1423    // --- B9 warm-with-health-gate tests -----------------------------------
1424
1425    /// `warm_with_health_gate` with a passing closure behaves exactly like
1426    /// the gate-less `warm`: revision lands `Ready`, runtime-config refresh
1427    /// runs, and the outcome envelope is the same shape.
1428    #[test]
1429    fn warm_with_passing_gate_lands_ready() {
1430        let dir = tempdir().unwrap();
1431        let store = LocalFsStore::new(dir.path());
1432        let did = seed_env_with_deployment(&store);
1433        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1434        let rid = staged
1435            .result
1436            .get("revision_id")
1437            .and_then(|v| v.as_str())
1438            .unwrap()
1439            .to_string();
1440        let warmed = warm_with_health_gate(
1441            &store,
1442            &OpFlags::default(),
1443            Some(RevisionTransitionPayload {
1444                environment_id: "local".to_string(),
1445                revision_id: rid,
1446                idempotency_key: None,
1447            }),
1448            |_env, _revision| Ok(()),
1449        )
1450        .unwrap();
1451        assert_eq!(
1452            warmed.result.get("lifecycle").and_then(|v| v.as_str()),
1453            Some("ready")
1454        );
1455    }
1456
1457    /// `warm_with_health_gate` with a failing closure surfaces a Conflict
1458    /// (from `OpError::From<LifecycleError>`) and persists the revision in
1459    /// `Failed`. The on-disk env reflects the failed warm so a follow-up
1460    /// `archive` / retry sees the real state.
1461    #[test]
1462    fn warm_with_failing_gate_persists_failed_and_returns_conflict() {
1463        let dir = tempdir().unwrap();
1464        let store = LocalFsStore::new(dir.path());
1465        let did = seed_env_with_deployment(&store);
1466        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1467        let rid_str = staged
1468            .result
1469            .get("revision_id")
1470            .and_then(|v| v.as_str())
1471            .unwrap()
1472            .to_string();
1473
1474        let err = warm_with_health_gate(
1475            &store,
1476            &OpFlags::default(),
1477            Some(RevisionTransitionPayload {
1478                environment_id: "local".to_string(),
1479                revision_id: rid_str.clone(),
1480                idempotency_key: None,
1481            }),
1482            |_env, _revision| {
1483                Err(crate::environment::HealthGateFailure {
1484                    failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1485                    message: "runtime-config.json missing".to_string(),
1486                })
1487            },
1488        )
1489        .unwrap_err();
1490        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
1491        let msg = format!("{err}");
1492        assert!(msg.contains("warm/ready health gate"), "msg: {msg}");
1493        assert!(msg.contains("RuntimeConfig"), "msg: {msg}");
1494
1495        // On-disk: revision is now Failed.
1496        let env_id = EnvId::try_from("local").unwrap();
1497        let env = store.load(&env_id).unwrap();
1498        assert_eq!(env.revisions.len(), 1);
1499        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1500    }
1501
1502    /// Codex finding 2 regression: when the health gate flips a revision to
1503    /// `Failed` (state committed) AND the audit-append subsequently fails,
1504    /// the audit boundary must fail-closed and surface `OpError::Audit` —
1505    /// NOT downgrade to `tracing::warn!` (the old default for `Err`
1506    /// returns). We trigger an audit-append failure by placing a regular
1507    /// file at `<env_dir>/audit`, so `AuditLog::append`'s `create_dir_all`
1508    /// errors with NotADirectory.
1509    #[test]
1510    fn warm_failing_gate_with_audit_failure_returns_audit_error() {
1511        let dir = tempdir().unwrap();
1512        let store = LocalFsStore::new(dir.path());
1513        let did = seed_env_with_deployment(&store);
1514        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1515        let rid_str = staged
1516            .result
1517            .get("revision_id")
1518            .and_then(|v| v.as_str())
1519            .unwrap()
1520            .to_string();
1521
1522        // Block audit appends: delete the existing events.jsonl (created by
1523        // the stage call above) and put a directory at that path instead, so
1524        // OpenOptions::open errors with IsADirectory.
1525        let env_id = EnvId::try_from("local").unwrap();
1526        let env_dir = store.env_dir(&env_id).unwrap();
1527        let events_path = env_dir.join("audit").join("events.jsonl");
1528        let _ = std::fs::remove_file(&events_path);
1529        std::fs::create_dir(&events_path).unwrap();
1530
1531        let err = warm_with_health_gate(
1532            &store,
1533            &OpFlags::default(),
1534            Some(RevisionTransitionPayload {
1535                environment_id: "local".to_string(),
1536                revision_id: rid_str.clone(),
1537                idempotency_key: None,
1538            }),
1539            |_env, _revision| {
1540                Err(crate::environment::HealthGateFailure {
1541                    failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1542                    message: "runtime-config.json missing".to_string(),
1543                })
1544            },
1545        )
1546        .unwrap_err();
1547
1548        // Fail-closed: audit failure on a committed gate-fail must surface
1549        // as OpError::Audit, NOT the closure's original Conflict.
1550        match &err {
1551            OpError::Audit(_) => {}
1552            other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1553        }
1554
1555        // On-disk lifecycle is still Failed (the gate persisted before the
1556        // audit attempt).
1557        let env = store.load(&env_id).unwrap();
1558        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Failed);
1559    }
1560
1561    /// Negative half of the Finding 2 regression: when a closure returns a
1562    /// NON-committed error (e.g. a NotFound from a typo'd revision_id) AND
1563    /// the audit append fails, the existing demote-to-warn behavior is
1564    /// preserved — the original error reaches the caller, not the audit
1565    /// error.
1566    #[test]
1567    fn warm_uncommitted_error_with_audit_failure_returns_original_error() {
1568        let dir = tempdir().unwrap();
1569        let store = LocalFsStore::new(dir.path());
1570        let _did = seed_env_with_deployment(&store);
1571
1572        // Block the audit dir.
1573        let env_id = EnvId::try_from("local").unwrap();
1574        let env_dir = store.env_dir(&env_id).unwrap();
1575        std::fs::write(env_dir.join("audit"), b"audit-blocker").unwrap();
1576
1577        // Reference a revision that doesn't exist → NotFound, nothing committed.
1578        let phantom_rid = ulid::Ulid::new().to_string();
1579        let err = warm(
1580            &store,
1581            &OpFlags::default(),
1582            Some(RevisionTransitionPayload {
1583                environment_id: "local".to_string(),
1584                revision_id: phantom_rid,
1585                idempotency_key: None,
1586            }),
1587        )
1588        .unwrap_err();
1589
1590        // Original error preserved (audit failure demoted to warn).
1591        match &err {
1592            OpError::NotFound(_) => {}
1593            other => panic!("expected OpError::NotFound (audit demoted); got `{other:?}`"),
1594        }
1595    }
1596
1597    /// Code-review regression: the `Ok` arm of `apply_revision_transition_
1598    /// with_health_gate` ALSO commits state (the lifecycle helper called
1599    /// `locked.save` before returning Ok), so subsequent failures inside
1600    /// the transact (load / refresh_runtime_config) are committed-on-error
1601    /// and must trigger fail-closed audit semantics.
1602    ///
1603    /// Scenario: passing gate advances Staged → Ready, lifecycle helper
1604    /// saves env.json (revision durably Ready), then
1605    /// `locked.refresh_runtime_config` fails because the `runtime-config
1606    /// .json` path is occupied by a directory; transact returns Err. If
1607    /// the audit append ALSO fails (events.jsonl blocked), the caller
1608    /// MUST see `OpError::Audit`, not the inner StoreError demoted to a
1609    /// warn.
1610    #[test]
1611    fn warm_ok_with_refresh_failure_and_audit_failure_returns_audit_error() {
1612        let dir = tempdir().unwrap();
1613        let store = LocalFsStore::new(dir.path());
1614        let did = seed_env_with_deployment(&store);
1615        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1616        let rid_str = staged
1617            .result
1618            .get("revision_id")
1619            .and_then(|v| v.as_str())
1620            .unwrap()
1621            .to_string();
1622
1623        let env_id = EnvId::try_from("local").unwrap();
1624        let env_dir = store.env_dir(&env_id).unwrap();
1625
1626        // Block `refresh_runtime_config` by occupying the runtime-config
1627        // path with a directory; both save_/delete_ paths fail with IO
1628        // errors when the target is a directory.
1629        std::fs::create_dir(env_dir.join("runtime-config.json")).unwrap();
1630
1631        // Block audit append on the same env (same directory-as-file trick
1632        // used by the gate-fail audit test).
1633        let events_path = env_dir.join("audit").join("events.jsonl");
1634        let _ = std::fs::remove_file(&events_path);
1635        std::fs::create_dir(&events_path).unwrap();
1636
1637        let err = warm_with_health_gate(
1638            &store,
1639            &OpFlags::default(),
1640            Some(RevisionTransitionPayload {
1641                environment_id: "local".to_string(),
1642                revision_id: rid_str,
1643                idempotency_key: None,
1644            }),
1645            |_env, _revision| Ok(()),
1646        )
1647        .unwrap_err();
1648
1649        // Fail-closed: the lifecycle helper saved (revision is now Ready
1650        // on disk) and refresh failed; audit failure on a committed-on-
1651        // error path must surface as OpError::Audit, NOT the original
1652        // OpError::Store from the refresh failure.
1653        match &err {
1654            OpError::Audit(_) => {}
1655            other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1656        }
1657
1658        // The lifecycle save committed before the refresh failed: revision
1659        // is Ready on disk.
1660        let env = store.load(&env_id).unwrap();
1661        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Ready);
1662    }
1663
1664    /// PR-3a.6 Codex regression: the typed drain verb's lifecycle helper
1665    /// `locked.save`s before `run_revision_transition`'s post-save
1666    /// reload / runtime-config refresh runs. If refresh fails AND the
1667    /// audit append fails, the typed-verb-shaped caller (`typed_transition`)
1668    /// must still fail-closed — same contract as
1669    /// `warm_ok_with_refresh_failure_and_audit_failure_returns_audit_error`,
1670    /// just via the `StoreError::CommittedAfterSave` wrapper instead of
1671    /// the closure-based path's direct mark_committed.
1672    #[test]
1673    fn drain_ok_with_refresh_failure_and_audit_failure_returns_audit_error() {
1674        let dir = tempdir().unwrap();
1675        let store = LocalFsStore::new(dir.path());
1676        let did = seed_env_with_deployment(&store);
1677        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1678        let rid_str = staged
1679            .result
1680            .get("revision_id")
1681            .and_then(|v| v.as_str())
1682            .unwrap()
1683            .to_string();
1684
1685        // Drain accepts only `Ready` as a start — flip the staged revision
1686        // directly instead of running the full warm dance (which isn't the
1687        // verb under test here).
1688        let env_id = EnvId::try_from("local").unwrap();
1689        let mut env = store.load(&env_id).unwrap();
1690        env.revisions[0].lifecycle = RevisionLifecycle::Ready;
1691        store.save(&env).unwrap();
1692
1693        let env_dir = store.env_dir(&env_id).unwrap();
1694
1695        // Block `refresh_runtime_config` AND `audit append` — same
1696        // directory-as-file trick used by the warm regression.
1697        let _ = std::fs::remove_file(env_dir.join("runtime-config.json"));
1698        std::fs::create_dir(env_dir.join("runtime-config.json")).unwrap();
1699        let events_path = env_dir.join("audit").join("events.jsonl");
1700        let _ = std::fs::remove_file(&events_path);
1701        std::fs::create_dir(&events_path).unwrap();
1702
1703        let err = drain(
1704            &store,
1705            &OpFlags::default(),
1706            Some(RevisionTransitionPayload {
1707                environment_id: "local".to_string(),
1708                revision_id: rid_str,
1709                idempotency_key: None,
1710            }),
1711        )
1712        .unwrap_err();
1713
1714        match &err {
1715            OpError::Audit(_) => {}
1716            other => panic!("expected OpError::Audit (fail-closed); got `{other:?}`"),
1717        }
1718
1719        // The lifecycle save committed before refresh failed.
1720        let env = store.load(&env_id).unwrap();
1721        assert_eq!(env.revisions[0].lifecycle, RevisionLifecycle::Draining);
1722    }
1723
1724    // -------------------------------------------------------------------
1725    // C5.3 — end-to-end rollout-event capture
1726    //
1727    // Codex's review found that emitting in scaffolded greentic-start paths
1728    // produced silent live operator flows. These tests drive the LIVE CLI
1729    // verbs (`warm`, `drain`, `archive`) and capture the resulting
1730    // `rollout.*` events through a global `tracing_subscriber` layer
1731    // (`crate::rollout_telemetry::test_capture`), so the verb→event mapping
1732    // is regression-tested through the same code path operator HTTP routes
1733    // use today.
1734    //
1735    // The shared capture infra uses one process-global subscriber + a
1736    // per-thread `Vec` because `tracing::subscriber::with_default` has
1737    // callsite-interest-cache races under parallel test execution — see
1738    // the module doc on `test_capture` for the full rationale.
1739    // -------------------------------------------------------------------
1740
1741    use crate::rollout_telemetry::test_capture::capture_events;
1742    use std::collections::BTreeSet;
1743
1744    /// Convert a flat captured event list into a `BTreeSet` for assert-by-
1745    /// membership, matching the prior `RolloutCapture::observed()` shape.
1746    fn observed(events: &[String]) -> BTreeSet<String> {
1747        events.iter().cloned().collect()
1748    }
1749
1750    /// Live `warm` CLI invocation must emit `rollout.health_gate.passed`
1751    /// and `rollout.revision.warmed` — Codex's "end-to-end warm test that
1752    /// asserts pass rollout events are observed" recommendation.
1753    #[test]
1754    fn warm_emits_health_gate_passed_and_revision_warmed() {
1755        let dir = tempdir().unwrap();
1756        let store = LocalFsStore::new(dir.path());
1757        let did = seed_env_with_deployment(&store);
1758        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1759        let rid = staged
1760            .result
1761            .get("revision_id")
1762            .and_then(|v| v.as_str())
1763            .unwrap()
1764            .to_string();
1765
1766        let (result, events) = capture_events(|| {
1767            warm(
1768                &store,
1769                &OpFlags::default(),
1770                Some(RevisionTransitionPayload {
1771                    environment_id: "local".to_string(),
1772                    revision_id: rid,
1773                    idempotency_key: None,
1774                }),
1775            )
1776        });
1777        result.unwrap();
1778        let observed = observed(&events);
1779        assert!(
1780            observed.contains("rollout.health_gate.passed"),
1781            "observed events: {observed:?}"
1782        );
1783        assert!(
1784            observed.contains("rollout.revision.warmed"),
1785            "observed events: {observed:?}"
1786        );
1787        // No failure event on a happy-path warm.
1788        assert!(!observed.contains("rollout.health_gate.failed"));
1789    }
1790
1791    /// Live `warm_with_health_gate` with a failing gate closure must emit
1792    /// `rollout.health_gate.failed` — Codex's "fail rollout events are
1793    /// observed" recommendation.
1794    #[test]
1795    fn warm_with_failing_gate_emits_health_gate_failed() {
1796        let dir = tempdir().unwrap();
1797        let store = LocalFsStore::new(dir.path());
1798        let did = seed_env_with_deployment(&store);
1799        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1800        let rid = staged
1801            .result
1802            .get("revision_id")
1803            .and_then(|v| v.as_str())
1804            .unwrap()
1805            .to_string();
1806
1807        let (result, events) = capture_events(|| {
1808            warm_with_health_gate(
1809                &store,
1810                &OpFlags::default(),
1811                Some(RevisionTransitionPayload {
1812                    environment_id: "local".to_string(),
1813                    revision_id: rid,
1814                    idempotency_key: None,
1815                }),
1816                |_env, _revision| {
1817                    Err(crate::environment::HealthGateFailure {
1818                        failed_checks: vec![crate::environment::HealthCheckId::RuntimeConfig],
1819                        message: "synthetic gate failure".to_string(),
1820                    })
1821                },
1822            )
1823        });
1824        result.unwrap_err();
1825        let observed = observed(&events);
1826        assert!(
1827            observed.contains("rollout.health_gate.failed"),
1828            "observed events: {observed:?}"
1829        );
1830        // No passing event when the gate failed.
1831        assert!(!observed.contains("rollout.health_gate.passed"));
1832        assert!(!observed.contains("rollout.revision.warmed"));
1833    }
1834
1835    /// Live `drain` CLI invocation must emit `rollout.revision.draining`.
1836    /// Drives the Ready → Draining transition through the same path the
1837    /// operator HTTP route uses.
1838    #[test]
1839    fn drain_emits_revision_draining() {
1840        let dir = tempdir().unwrap();
1841        let store = LocalFsStore::new(dir.path());
1842        let did = seed_env_with_deployment(&store);
1843        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1844        let rid = staged
1845            .result
1846            .get("revision_id")
1847            .and_then(|v| v.as_str())
1848            .unwrap()
1849            .to_string();
1850        // Walk Staged → Warming → Ready so the drain matrix has a valid `from`.
1851        warm(
1852            &store,
1853            &OpFlags::default(),
1854            Some(RevisionTransitionPayload {
1855                environment_id: "local".to_string(),
1856                revision_id: rid.clone(),
1857                idempotency_key: None,
1858            }),
1859        )
1860        .unwrap();
1861
1862        let (result, events) = capture_events(|| {
1863            drain(
1864                &store,
1865                &OpFlags::default(),
1866                Some(RevisionTransitionPayload {
1867                    environment_id: "local".to_string(),
1868                    revision_id: rid,
1869                    idempotency_key: None,
1870                }),
1871            )
1872        });
1873        result.unwrap();
1874        let observed = observed(&events);
1875        assert!(
1876            observed.contains("rollout.revision.draining"),
1877            "observed events: {observed:?}"
1878        );
1879    }
1880
1881    /// Live `archive` taking the Draining → Inactive chain must emit
1882    /// `rollout.revision.evicted`. Other archive chains (e.g. Ready →
1883    /// Archived) must NOT emit `evicted` — that's lifecycle retirement,
1884    /// not a rollout eviction.
1885    #[test]
1886    fn archive_emits_revision_evicted_on_draining_to_inactive() {
1887        let dir = tempdir().unwrap();
1888        let store = LocalFsStore::new(dir.path());
1889        let did = seed_env_with_deployment(&store);
1890        let staged = stage(&store, &OpFlags::default(), Some(stage_payload(&did))).unwrap();
1891        let rid = staged
1892            .result
1893            .get("revision_id")
1894            .and_then(|v| v.as_str())
1895            .unwrap()
1896            .to_string();
1897        // Walk Staged → Warming → Ready → Draining so archive lands on the
1898        // Draining → Inactive chain (the post-drain eviction hop).
1899        warm(
1900            &store,
1901            &OpFlags::default(),
1902            Some(RevisionTransitionPayload {
1903                environment_id: "local".to_string(),
1904                revision_id: rid.clone(),
1905                idempotency_key: None,
1906            }),
1907        )
1908        .unwrap();
1909        drain(
1910            &store,
1911            &OpFlags::default(),
1912            Some(RevisionTransitionPayload {
1913                environment_id: "local".to_string(),
1914                revision_id: rid.clone(),
1915                idempotency_key: None,
1916            }),
1917        )
1918        .unwrap();
1919
1920        let (result, events) = capture_events(|| {
1921            archive(
1922                &store,
1923                &OpFlags::default(),
1924                Some(RevisionTransitionPayload {
1925                    environment_id: "local".to_string(),
1926                    revision_id: rid,
1927                    idempotency_key: None,
1928                }),
1929            )
1930        });
1931        result.unwrap();
1932        let observed = observed(&events);
1933        assert!(
1934            observed.contains("rollout.revision.evicted"),
1935            "observed events: {observed:?}"
1936        );
1937    }
1938}