Skip to main content

greentic_deployer/cli/
env_apply.rs

1//! `gtc op env apply` — declarative, upsert-only environment apply
2//! (PR-1 + PR-2 of `plans/env-manifest-apply.md`).
3//!
4//! Consumes a `greentic.env-manifest.v1` document (via the standard
5//! `--answers <PATH>` payload convention) and reconciles the environment
6//! toward it: **validate → diff → plan → execute → verify**.
7//!
8//! - Upsert-only: resources in the store but absent from the manifest are
9//!   left untouched. No pruning, no deletes (v1 decision).
10//! - Wiring only: the manifest starts at "artifacts exist" — no pack or
11//!   bundle building.
12//! - Safe re-run: a second apply of an unchanged manifest is a visible
13//!   no-op; a re-run after a partial failure completes the remainder
14//!   (fail-fast, no rollback — every composed verb leaves the store valid,
15//!   so the store itself is the checkpoint).
16//! - Cross-step references resolve in-process: `add-endpoint` returns the
17//!   store-assigned `endpoint_id`, which the subsequent `link-endpoint` /
18//!   `set-welcome-flow` steps consume — no output-capture plumbing.
19//! - Deterministic idempotency keys: derived from
20//!   `(schema, env, step kind, natural key, desired-state hash)` so a
21//!   re-run replays instead of double-mutating (HTTP-store dedupe
22//!   compatible). Exceptions: `deploy`'s traffic cut-over key intentionally
23//!   stays per-revision-derived (a re-stage is by definition a new
24//!   cut-over); `put-secret` carries no deterministic key (an always-put is
25//!   by definition a new write — a value-insensitive key would conflate
26//!   rotations in audit and break A8 same-key-different-body semantics;
27//!   `secrets::put` mints a fresh per-invocation key).
28//! - Store-level verify only: *runtime* readiness (secrets resolvable by
29//!   the reader, routes served) is `gtc doctor`'s job — apply must work
30//!   without a running runtime.
31//! - `--check` (CI convergence gate): validate + diff + plan, never
32//!   mutate; exit non-zero when diffable changes are pending. Always-put
33//!   secret rows are excluded from the verdict (values cannot be diffed
34//!   until A9) and reported under `undiffable` instead.
35//! - Missing-inputs contract: an unset `from_env` variable or an absent
36//!   `bundle_path` artifact is *collected* (not fail-fast) and reported
37//!   under `missing` in the JSON report, so one run surfaces every gap.
38//!   Mutating apply refuses to execute while any remain; on a TTY, missing
39//!   secret values are prompted for (masked, in-memory only).
40//!   `--non-interactive` never prompts and implies `--yes`; `--dry-run` and
41//!   `--check` report missing inputs without failing on them (`--check`
42//!   excludes them from the convergence verdict — CI gates must be runnable
43//!   without holding credentials).
44//!
45//! The `--emit-answers-template <path>` shortcut writes a skeleton manifest
46//! to start from — it is dispatched as a peer verb mode
47//! ([`emit_answers_template`]) before the apply engine is entered, because
48//! it needs no store, no manifest, and no flags.
49//!
50//! - Secrets are always-put: `op secrets get` is not-yet-implemented, so
51//!   values cannot be diffed — the plan says `put (cannot diff)` instead of
52//!   ever claiming a false no-op. Values resolve from `from_env` process
53//!   variables at validation time and never appear in the manifest, plan,
54//!   report, or audit records.
55//!
56//! Every mutation is executed through the existing single-purpose verb
57//! functions (`deploy::deploy`, `bundles::update`, `messaging::add`/…,
58//! `secrets::put`, `trust_root::bootstrap`, `env::init`/`set_public_url`),
59//! so audit, authorization, signing, and revenue-policy logic stay
60//! single-sourced — each step lands its own audit event under the composed
61//! verb's noun.
62//!
63//! Human-readable plan/progress lines go to **stderr**; stdout carries only
64//! the standard `{op, noun, result}` JSON envelope (so the output is
65//! already machine-readable — no separate `--json` flag).
66
67use std::collections::{BTreeMap, BTreeSet};
68use std::io::IsTerminal;
69use std::path::{Path, PathBuf};
70
71use greentic_deploy_spec::{
72    BundleDeploymentStatus, CapabilitySlot, CustomerId, DeploymentId, EnvId, Environment,
73    MessagingEndpoint, RouteBinding,
74};
75use serde_json::{Value, json};
76use sha2::{Digest, Sha256};
77
78use crate::environment::{EnvironmentStore, LocalFsStore, trust_root as store_trust_root};
79use crate::runtime_secrets::SecretValue;
80
81use super::bundles::{
82    BundleUpdatePayload, RevenueShareEntryPayload, RouteBindingPayload, convert_revenue_share,
83    into_route_binding,
84};
85use super::config::ConfigSetPayload;
86use super::deploy::BundleDeployPayload;
87use super::env::EnvInitPayload;
88use super::env_manifest::{
89    ENV_MANIFEST_SCHEMA_V1, EnvManifest, ManifestBundle, ManifestEndpoint, ManifestRevision,
90    ManifestWelcomeFlow, TrustRootDirective, compute_effective_weights_bps, manifest_schema,
91};
92use super::env_packs::EnvPackBindingPayload;
93use super::extensions::ExtensionBindingPayload;
94use super::messaging::{
95    EndpointAddPayload, EndpointLinkBundlePayload, EndpointSetWelcomeFlowPayload, EndpointSummary,
96};
97use super::secrets::SecretsPutPayload;
98use super::trust_root::TrustRootBootstrapPayload;
99use super::{OpError, OpFlags, OpOutcome};
100
101const NOUN: &str = "env";
102const VERB: &str = "apply";
103
104/// Audit principal stamped on every composed mutation when the caller
105/// doesn't pass `--updated-by`.
106const DEFAULT_UPDATED_BY: &str = "env-apply";
107
108// --- plan model ---------------------------------------------------------------
109
110/// What the diff decided for one step. `Put` is the secrets-only
111/// "cannot diff, write unconditionally" action (`op secrets get` is
112/// not-yet-implemented for every backend, so a no-op can never be claimed);
113/// warning-carrying no-ops surface through the plan's `warnings` list
114/// instead of a dedicated `Skip` action.
115#[derive(Debug, Clone, Copy, PartialEq, Eq)]
116enum ApplyAction {
117    Create,
118    Update,
119    Put,
120    NoOp,
121}
122
123impl ApplyAction {
124    fn as_str(self) -> &'static str {
125        match self {
126            ApplyAction::Create => "create",
127            ApplyAction::Update => "update",
128            ApplyAction::Put => "put",
129            ApplyAction::NoOp => "no-op",
130        }
131    }
132
133    /// Does this action represent *diffable* drift for the `--check`
134    /// convergence verdict? `Put` is excluded: always-put secret rows are
135    /// unknowable until A9 lands a real `secrets get`, so counting them
136    /// would make `--check` permanently red for any manifest with a
137    /// `secrets[]` section. They stay visible in the report (`put` rows
138    /// plus the `undiffable` count) instead of failing the gate.
139    fn counts_as_drift(self) -> bool {
140        matches!(self, ApplyAction::Create | ApplyAction::Update)
141    }
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145enum ApplyStepKind {
146    EnsureEnvironment,
147    UpdateHostConfig,
148    BootstrapTrustRoot,
149    AddPackBinding,
150    UpdatePackBinding,
151    PutSecret,
152    DeployBundle,
153    DeploySplit,
154    UpdateBundle,
155    AddExtension,
156    UpdateExtension,
157    AddEndpoint,
158    LinkEndpoint,
159    SetWelcomeFlow,
160}
161
162impl ApplyStepKind {
163    fn label(self) -> &'static str {
164        match self {
165            ApplyStepKind::EnsureEnvironment => "ensure-environment",
166            ApplyStepKind::UpdateHostConfig => "update-host-config",
167            ApplyStepKind::BootstrapTrustRoot => "bootstrap-trust-root",
168            ApplyStepKind::AddPackBinding => "add-pack-binding",
169            ApplyStepKind::UpdatePackBinding => "update-pack-binding",
170            ApplyStepKind::PutSecret => "put-secret",
171            ApplyStepKind::DeployBundle => "deploy-bundle",
172            ApplyStepKind::DeploySplit => "deploy-split",
173            ApplyStepKind::UpdateBundle => "update-bundle",
174            ApplyStepKind::AddExtension => "add-extension",
175            ApplyStepKind::UpdateExtension => "update-extension",
176            ApplyStepKind::AddEndpoint => "add-endpoint",
177            ApplyStepKind::LinkEndpoint => "link-endpoint",
178            ApplyStepKind::SetWelcomeFlow => "set-welcome-flow",
179        }
180    }
181}
182
183/// One revision inside a [`StepOp::DeploySplit`]. Carries the resolved
184/// artifact path and digest for TOCTOU re-verification, plus the effective
185/// weight already computed by [`compute_effective_weights_bps`].
186#[derive(Debug, Clone)]
187struct SplitRevisionEntry {
188    name: String,
189    resolved_path: PathBuf,
190    expected_digest: String,
191    weight_bps: u32,
192    drain_seconds: Option<u32>,
193    /// OCI/repo/store pull ref for the staged revision (K8s boot pull);
194    /// `None` = local-serve only.
195    bundle_source_uri: Option<String>,
196}
197
198/// Reference to an endpoint that may not exist yet at plan time. `Created`
199/// is resolved in-process during execution from the `add-endpoint` outcome.
200#[derive(Debug, Clone)]
201enum EndpointRef {
202    Existing(String),
203    CreatedByName(String),
204}
205
206/// The typed mutation a step performs at execute time. `None` for no-ops.
207#[derive(Debug, Clone)]
208enum StepOp {
209    None,
210    EnvInit {
211        public_base_url: Option<String>,
212    },
213    SetPublicUrl {
214        url: String,
215    },
216    TrustRootBootstrap,
217    /// The resolved VALUE deliberately does not live in the step (steps
218    /// derive `Debug` and serialize into the plan/report) — execute looks it
219    /// up in [`ApplyContext::secret_values`] by this path.
220    PutSecret {
221        path: String,
222    },
223    Deploy {
224        payload: Box<BundleDeployPayload>,
225        /// Digest recorded at plan time; re-verified just before the deploy
226        /// step executes to shrink the validate→execute TOCTOU window.
227        expected_digest: String,
228    },
229    /// Multi-revision traffic-split deploy: stage each revision, warm it,
230    /// then set the combined traffic split. Each entry carries its own
231    /// artifact path and digest for TOCTOU re-verification.
232    DeploySplit {
233        env_id: String,
234        bundle_id: String,
235        customer_id: Option<String>,
236        config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
237        route_binding: Option<RouteBindingPayload>,
238        /// Revenue-share split applied on the FRESH-add path (`None` =
239        /// `greentic@10000`). Reconciled via `bundles update` for an
240        /// existing deployment.
241        revenue_share: Option<Vec<RevenueShareEntryPayload>>,
242        revisions: Vec<SplitRevisionEntry>,
243    },
244    UpdateHostConfig(Box<ConfigSetPayload>),
245    AddPackBinding(Box<EnvPackBindingPayload>),
246    UpdatePackBinding(Box<EnvPackBindingPayload>),
247    AddExtension(Box<ExtensionBindingPayload>),
248    UpdateExtension(Box<ExtensionBindingPayload>),
249    BundleUpdate(Box<BundleUpdatePayload>),
250    EndpointAdd(Box<EndpointAddPayload>),
251    EndpointLink {
252        endpoint: EndpointRef,
253        bundle_id: String,
254    },
255    WelcomeFlow {
256        endpoint: EndpointRef,
257        flow: ManifestWelcomeFlow,
258    },
259}
260
261#[derive(Debug, Clone)]
262struct ApplyStep {
263    kind: ApplyStepKind,
264    key: String,
265    action: ApplyAction,
266    detail: String,
267    idempotency_key: Option<String>,
268    op: StepOp,
269}
270
271impl ApplyStep {
272    fn no_op(kind: ApplyStepKind, key: impl Into<String>, detail: impl Into<String>) -> Self {
273        Self {
274            kind,
275            key: key.into(),
276            action: ApplyAction::NoOp,
277            detail: detail.into(),
278            idempotency_key: None,
279            op: StepOp::None,
280        }
281    }
282
283    fn to_json(&self) -> Value {
284        json!({
285            "kind": self.kind.label(),
286            "key": self.key,
287            "action": self.action.as_str(),
288            "detail": self.detail,
289            "idempotency_key": self.idempotency_key,
290        })
291    }
292}
293
294// --- validated context ---------------------------------------------------------
295
296/// One resolved revision inside a multi-revision bundle entry.
297struct ResolvedRevision {
298    spec: ManifestRevision,
299    resolved_path: PathBuf,
300    digest: String,
301    /// Effective weight in basis points, computed by
302    /// [`compute_effective_weights_bps`].
303    weight_bps: u32,
304}
305
306/// A manifest bundle entry paired with its resolved artifact metadata.
307/// For single-revision entries (`bundle_path` form), `revisions` holds
308/// exactly one entry at full weight. Multi-revision entries hold one per
309/// declared revision.
310struct ResolvedBundle {
311    spec: ManifestBundle,
312    /// Billing principal resolved during validation (from
313    /// `resolve_customer_id`). Used to match deployments by the same
314    /// `(bundle_id, customer_id)` pair that `op deploy` keys on.
315    customer_id: CustomerId,
316    /// Resolved revisions. Always non-empty after validation.
317    revisions: Vec<ResolvedRevision>,
318}
319
320/// A manifest endpoint entry paired with its store match (if any).
321struct ResolvedEndpoint {
322    spec: ManifestEndpoint,
323    /// Cloned from the store — `None` when the endpoint is new.
324    matched: Option<MessagingEndpoint>,
325}
326
327/// Everything validation established, handed to diff/execute/verify.
328struct ApplyContext {
329    env_id: EnvId,
330    manifest: EnvManifest,
331    /// Secret values keyed by the manifest's verbatim `path`, resolved from
332    /// `from_env` during validation. Never serialized; [`SecretValue`]'s
333    /// `Debug` renders a fixed placeholder, so no derived-`Debug` surface
334    /// can echo the material — the value reaches exactly one place, the
335    /// `secrets::put` payload at execute time.
336    secret_values: BTreeMap<String, SecretValue>,
337    /// Secret paths whose value came from the TTY prompter rather than the
338    /// named env var — the plan row says `prompted` instead of `from $VAR`.
339    prompted_paths: BTreeSet<String>,
340    /// Paste-sourced secret paths already present in the env's store (no value
341    /// to collect or put — store-as-source-of-truth). These get neither a put
342    /// step nor a missing-input report; `diff` skips them entirely.
343    store_satisfied_paths: BTreeSet<String>,
344    bundles: Vec<ResolvedBundle>,
345    endpoints: Vec<ResolvedEndpoint>,
346    env: Option<Environment>,
347    /// Canonicalized `environment.public_base_url` (validated form).
348    canonical_public_base_url: Option<String>,
349    /// Accumulated input gaps (see [`MissingItem`]): secrets in manifest
350    /// order, then bundles in manifest order.
351    missing: Vec<MissingItem>,
352    warnings: Vec<String>,
353    updated_by: String,
354    /// Directory of the manifest file. Pack-binding `answers_ref`s resolve
355    /// against it, and apply stages them into the env store so reconcile
356    /// (which resolves against the env dir) can find them.
357    manifest_dir: PathBuf,
358}
359
360// --- entry point ----------------------------------------------------------------
361
362/// An input the manifest names but the process cannot supply: an unset
363/// (or empty) `from_env` variable, or a `bundle_path` that is not a file.
364/// Missing inputs are *accumulated* during validation (unlike structural
365/// manifest errors, which stay fail-fast) so headless callers see the
366/// complete list in one run, and reported under `missing` in the JSON
367/// report. Apply mode refuses to execute while any remain; preview modes
368/// (`--dry-run`, `--check`) report them without failing.
369#[derive(Debug, Clone, PartialEq, Eq)]
370struct MissingItem {
371    kind: MissingKind,
372    /// Natural key: the secret's manifest `path`, or the bundle's
373    /// `bundle_id`.
374    key: String,
375    /// Where the input was expected: `env:<VAR>` or `path:<resolved path>`.
376    source: String,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380enum MissingKind {
381    SecretValue,
382    BundleArtifact,
383}
384
385impl MissingKind {
386    fn as_str(self) -> &'static str {
387        match self {
388            MissingKind::SecretValue => "secret_value",
389            MissingKind::BundleArtifact => "bundle_artifact",
390        }
391    }
392}
393
394impl MissingItem {
395    fn to_json(&self) -> Value {
396        json!({
397            "kind": self.kind.as_str(),
398            "key": self.key,
399            "source": self.source,
400        })
401    }
402}
403
404/// How `apply` terminates once the plan is computed.
405#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
406pub enum ApplyMode {
407    /// Execute the plan and verify (the default).
408    #[default]
409    Apply,
410    /// Print the plan and exit 0 without mutating — a preview, even when
411    /// changes are pending.
412    DryRun,
413    /// CI convergence gate: print the plan, never mutate, and fail
414    /// (non-zero exit) when diffable changes are pending. Always-put
415    /// secret rows don't count as drift ([`ApplyAction::counts_as_drift`]).
416    Check,
417}
418
419impl ApplyMode {
420    pub(super) fn as_str(self) -> &'static str {
421        match self {
422            ApplyMode::Apply => "apply",
423            ApplyMode::DryRun => "dry-run",
424            ApplyMode::Check => "check",
425        }
426    }
427}
428
429/// Knobs for [`apply`] beyond the global `OpFlags`. Built from
430/// `EnvApplyArgs` on the CLI path; library callers (greentic-setup's env
431/// mode) construct it directly — `Default` is a plain mutating apply.
432///
433/// The `--emit-answers-template` shortcut is dispatched as a peer verb
434/// mode ([`emit_answers_template`]) before `ApplyOptions` is constructed,
435/// so it does not appear here.
436#[derive(Debug, Clone, Default)]
437pub struct ApplyOptions {
438    pub mode: ApplyMode,
439    /// Audit principal forwarded to every composed mutation. Defaults to
440    /// `env-apply`.
441    pub updated_by: Option<String>,
442    /// Skip the interactive plan confirmation.
443    pub yes: bool,
444    /// Never prompt — neither for missing secret values nor the plan
445    /// confirmation (implies `yes`). Missing inputs are collected and
446    /// reported instead of asked for.
447    pub non_interactive: bool,
448    /// Pre-collected values for paste-sourced secrets (manifest `from_env`
449    /// absent), keyed by the manifest secret `path`. An interactive author
450    /// (the `gtc setup --env` wizard) collects each value once and hands it
451    /// here so apply does not prompt again; env-sourced secrets ignore this.
452    /// Never serialized — `SecretValue`'s `Debug` renders a placeholder.
453    pub prefilled_secrets: BTreeMap<String, SecretValue>,
454}
455
456/// Write the skeleton `greentic.env-manifest.v1` template to `path`.
457///
458/// This is the peer verb mode for `--emit-answers-template`: it needs no
459/// store, no manifest, and no flags — dispatched before `apply` is
460/// entered so the template write is decoupled from the apply engine's
461/// precondition stack.
462pub fn emit_answers_template(path: &Path) -> Result<OpOutcome, OpError> {
463    std::fs::write(path, super::env_manifest::MANIFEST_TEMPLATE_JSON).map_err(|source| {
464        OpError::Io {
465            path: path.to_path_buf(),
466            source,
467        }
468    })?;
469    Ok(OpOutcome::new(
470        NOUN,
471        VERB,
472        json!({
473            "manifest_schema": ENV_MANIFEST_SCHEMA_V1,
474            "mode": "emit-answers-template",
475            "path": path,
476        }),
477    ))
478}
479
480/// `gtc op env apply --answers <manifest.json> [--dry-run | --check |
481/// --non-interactive] [--updated-by <who>] [--yes]`.
482pub fn apply(
483    store: &LocalFsStore,
484    flags: &OpFlags,
485    opts: ApplyOptions,
486) -> Result<OpOutcome, OpError> {
487    // TTY fill-in is offered only when the run could actually mutate
488    // (prompting during a preview is noise) and the operator can answer.
489    // rpassword prompts and reads on /dev/tty directly, so a redirected
490    // stdout never sees the prompt and the JSON envelope stays clean.
491    let interactive = opts.mode == ApplyMode::Apply
492        && !opts.non_interactive
493        && std::io::stdin().is_terminal()
494        && std::io::stderr().is_terminal();
495    let prompter: Option<&SecretPrompter> = if interactive {
496        Some(&prompt_secret_value)
497    } else {
498        None
499    };
500    apply_with_lookups(
501        store,
502        flags,
503        opts,
504        &|name| std::env::var(name).ok(),
505        prompter,
506    )
507}
508
509/// Asked for a secret value: `(manifest path, from_env name) -> value`.
510/// For an env-sourced secret this is the fallback when `$from_env` is unset;
511/// for a paste-sourced secret (`from_env` absent) the caller passes an empty
512/// `from_env`, and this is the primary collection path. `None` means "still
513/// missing" — the path lands in the missing-inputs report.
514type SecretPrompter = dyn Fn(&str, &str) -> Option<String>;
515
516/// Masked TTY prompt for one secret value. Empty input declines — the path
517/// stays missing and apply aborts with the full report. The value lives only
518/// in the in-memory [`SecretValue`] map, exactly like an env-resolved one; it
519/// never reaches the manifest, plan, report, or audit. An empty `from_env`
520/// marks a paste-sourced secret (no env var to name), changing the wording
521/// from "unset variable" to a plain value prompt.
522fn prompt_secret_value(path: &str, from_env: &str) -> Option<String> {
523    let prompt = if from_env.is_empty() {
524        format!("secret `{path}`: enter value (hidden; empty to abort): ")
525    } else {
526        format!("secret `{path}`: ${from_env} is unset — enter value (hidden; empty to abort): ")
527    };
528    let value = rpassword::prompt_password(prompt).ok()?;
529    (!value.is_empty()).then_some(value)
530}
531
532/// [`apply`] with the `from_env` secret-value lookup and the TTY prompter
533/// injected. Tests pass fakes so they never mutate the process environment
534/// (`set_var` is unsafe under a multithreaded test harness) and never need
535/// a TTY.
536fn apply_with_lookups(
537    store: &LocalFsStore,
538    flags: &OpFlags,
539    opts: ApplyOptions,
540    env_lookup: &dyn Fn(&str) -> Option<String>,
541    prompter: Option<&SecretPrompter>,
542) -> Result<OpOutcome, OpError> {
543    if flags.schema_only {
544        return Ok(OpOutcome::new(NOUN, VERB, manifest_schema()));
545    }
546    let ApplyOptions {
547        mode,
548        updated_by,
549        yes,
550        non_interactive,
551        prefilled_secrets,
552    } = opts;
553    let yes = yes || non_interactive;
554    let manifest_path = flags.answers.clone().ok_or_else(|| {
555        OpError::InvalidArgument(
556            "env apply requires `--answers <manifest.json>` (a greentic.env-manifest.v1 \
557             document; see `gtc op env apply --schema`)"
558                .to_string(),
559        )
560    })?;
561    let manifest: EnvManifest = super::load_answers(&manifest_path)?;
562    manifest.validate_shape()?;
563    let manifest_dir = manifest_path
564        .parent()
565        .filter(|p| !p.as_os_str().is_empty())
566        .unwrap_or_else(|| Path::new("."))
567        .to_path_buf();
568    let updated_by = updated_by.unwrap_or_else(|| DEFAULT_UPDATED_BY.to_string());
569
570    let ctx = resolve_and_validate(
571        store,
572        manifest,
573        &manifest_dir,
574        updated_by,
575        env_lookup,
576        prompter,
577        &prefilled_secrets,
578    )?;
579    let steps = diff(store, &ctx)?;
580    render_plan(&steps, &ctx.warnings, &ctx.missing);
581
582    match mode {
583        ApplyMode::DryRun => {
584            return Ok(OpOutcome::new(
585                NOUN,
586                VERB,
587                report_json(&ctx, &steps, mode.as_str(), None),
588            ));
589        }
590        ApplyMode::Check => {
591            // Missing inputs are reported but deliberately NOT drift: a CI
592            // convergence gate must be runnable without holding the secret
593            // values themselves (they're excluded from the verdict as
594            // undiffable anyway). Mutating apply still requires them below.
595            let diffable_pending = steps.iter().filter(|s| s.action.counts_as_drift()).count();
596            if diffable_pending > 0 {
597                return Err(OpError::Conflict(format!(
598                    "env `{}` is not converged: {diffable_pending} pending change(s) — \
599                     run `gtc op env apply --answers <manifest>` to reconcile (see the \
600                     plan above for the step list)",
601                    ctx.env_id.as_str()
602                )));
603            }
604            return Ok(OpOutcome::new(
605                NOUN,
606                VERB,
607                report_json(&ctx, &steps, mode.as_str(), None),
608            ));
609        }
610        ApplyMode::Apply => {}
611    }
612
613    // Mutating apply refuses to run while any input is missing — the plan
614    // above already lists every gap (the whole point of accumulating them),
615    // so the operator fixes all of them in one round trip.
616    if !ctx.missing.is_empty() {
617        return Err(OpError::InvalidArgument(format!(
618            "cannot apply: {} missing input(s): {} — export the named variable(s) / \
619             provide the artifact(s) and re-run (on a TTY, missing secret values are \
620             prompted for)",
621            ctx.missing.len(),
622            ctx.missing
623                .iter()
624                .map(|m| format!("{} `{}` ({})", m.kind.as_str(), m.key, m.source))
625                .collect::<Vec<_>>()
626                .join(", "),
627        )));
628    }
629
630    let pending = steps
631        .iter()
632        .filter(|s| s.action != ApplyAction::NoOp)
633        .count();
634    if pending > 0 && !yes && std::io::stdin().is_terminal() && std::io::stdout().is_terminal() {
635        eprint!(
636            "apply {pending} change(s) to env `{}`? [y/N] ",
637            ctx.env_id.as_str()
638        );
639        let mut line = String::new();
640        std::io::stdin()
641            .read_line(&mut line)
642            .map_err(|source| OpError::Io {
643                path: PathBuf::from("<stdin>"),
644                source,
645            })?;
646        let answer = line.trim().to_ascii_lowercase();
647        if answer != "y" && answer != "yes" {
648            return Err(OpError::InvalidArgument(
649                "aborted by user (pass --yes to skip the confirmation)".to_string(),
650            ));
651        }
652    }
653
654    execute(store, &ctx, &steps)?;
655    let verify = verify(store, &ctx)?;
656    Ok(OpOutcome::new(
657        NOUN,
658        VERB,
659        report_json(&ctx, &steps, "apply", Some(verify)),
660    ))
661}
662
663// --- validate -------------------------------------------------------------------
664
665fn resolve_and_validate(
666    store: &LocalFsStore,
667    manifest: EnvManifest,
668    manifest_dir: &Path,
669    updated_by: String,
670    env_lookup: &dyn Fn(&str) -> Option<String>,
671    prompter: Option<&SecretPrompter>,
672    prefilled_secrets: &BTreeMap<String, SecretValue>,
673) -> Result<ApplyContext, OpError> {
674    let env_id = EnvId::try_from(manifest.environment.id.as_str())
675        .map_err(|e| OpError::InvalidArgument(format!("environment.id: {e}")))?;
676    let canonical_public_base_url =
677        super::env::parse_optional_public_base_url(&manifest.environment.public_base_url)?;
678
679    let env = if store.exists(&env_id)? {
680        Some(store.load(&env_id)?)
681    } else {
682        // `env apply` bootstraps only the `local` env (its `env init` step
683        // seeds the default bindings). A named env is first-class on the local
684        // store but must be created explicitly first — `apply` reconciles an
685        // existing named env, it does not bootstrap one. Surface a clear error
686        // at plan time rather than failing mid-execute.
687        if env_id.as_str() != crate::defaults::LOCAL_ENV_ID {
688            return Err(OpError::NotFound(format!(
689                "environment `{env_id}` not found — `env apply` reconciles an existing \
690                 named environment but does not create one. Run `op env create {env_id}` \
691                 first, then re-run apply (or use `local`, which apply bootstraps)."
692            )));
693        }
694        None
695    };
696
697    // Secrets: resolve every `from_env` (set + non-empty) and pre-check the
698    // bound secrets backend, all before any mutation. Values land ONLY in
699    // `secret_values` (never in steps, plan, report, or audit targets).
700    // Path canonicality was already checked by `validate_shape`.
701    // Backend pre-check applies only when the env already exists — a fresh
702    // env is exempt because the `env init` step creates the default
703    // dev-store binding before any put-secret step runs.
704    if !manifest.secrets.is_empty()
705        && let Some(env) = &env
706    {
707        let secrets_pack = super::secrets::require_secrets_pack(env, &env_id)?;
708        if secrets_pack.kind.path() != super::secrets::DEV_STORE_KIND_PATH {
709            return Err(OpError::NotYetImplemented(format!(
710                "manifest secrets[] write through the dev-store only; env `{env_id}` \
711                 binds `{}` — backend dispatch beyond the dev-store lands in A9 \
712                 (env-pack registry)",
713                secrets_pack.kind
714            )));
715        }
716    }
717    // An unresolvable value is a MISSING INPUT, not a structural error:
718    // collect every gap (offering the TTY prompter as a fallback) instead
719    // of failing on the first, so one run reports the complete list.
720    //
721    // Per-secret resolution by source:
722    // - env-sourced (`from_env: Some`): read `$from_env`, falling back to an
723    //   interactive masked prompt when it is unset (today's behavior).
724    // - paste-sourced (`from_env: None`): use an author-supplied value
725    //   (`prefilled_secrets`, the wizard hand-off) if present; else, if the
726    //   value is already in the env's secrets store, it is satisfied with no
727    //   re-prompt and no put (the store is the source of truth for pasted
728    //   values); else prompt interactively; else report it missing.
729    let env_dir = store.env_dir(&env_id)?;
730    let mut missing = Vec::new();
731    let mut secret_values = BTreeMap::new();
732    let mut prompted_paths = BTreeSet::new();
733    let mut store_satisfied_paths = BTreeSet::new();
734    for s in &manifest.secrets {
735        let value = match &s.from_env {
736            Some(from_env) => match env_lookup(from_env).filter(|v| !v.is_empty()) {
737                Some(v) => Some(v),
738                None => prompter.and_then(|p| {
739                    let v = p(&s.path, from_env).filter(|v| !v.is_empty());
740                    if v.is_some() {
741                        prompted_paths.insert(s.path.clone());
742                    }
743                    v
744                }),
745            },
746            None => {
747                // An author-supplied value wins. An EMPTY prefilled value is
748                // treated as no value — consistent with the env/prompt branches
749                // (which filter empties) — so it falls through to the store
750                // check / prompt / missing path instead of reaching
751                // `secrets::put`, which rejects empties only at execute, after
752                // earlier steps have already mutated state.
753                let prefilled = prefilled_secrets
754                    .get(&s.path)
755                    .map(|value| value.expose())
756                    .filter(|value| !value.is_empty());
757                if let Some(value) = prefilled {
758                    Some(value.to_string())
759                } else if super::secrets::dev_store_has(&env_dir, &env_id, &s.path)? {
760                    // Already stored — nothing to collect or put. Skip
761                    // entirely: not a value, not a missing input.
762                    store_satisfied_paths.insert(s.path.clone());
763                    continue;
764                } else {
765                    // Empty `from_env` selects the paste-prompt wording.
766                    prompter.and_then(|p| p(&s.path, "").filter(|v| !v.is_empty()))
767                }
768            }
769        };
770        match value {
771            Some(v) => {
772                secret_values.insert(s.path.clone(), SecretValue::from(v));
773            }
774            None => missing.push(MissingItem {
775                kind: MissingKind::SecretValue,
776                key: s.path.clone(),
777                source: match &s.from_env {
778                    Some(from_env) => format!("env:{from_env}"),
779                    None => "paste".to_string(),
780                },
781            }),
782        }
783    }
784
785    // Bundle artifacts: existence + digest, plus the B10 billing-principal
786    // rule, all before any mutation. The principal rule stays fail-fast
787    // (a manifest bug); an absent artifact is a missing input — the bundle
788    // is reported and skipped (no digest means nothing to diff against).
789    let mut resolved_bundles = Vec::with_capacity(manifest.bundles.len());
790    for b in &manifest.bundles {
791        let customer_id = super::bundles::resolve_customer_id(&env_id, b.customer_id.clone())?;
792
793        // Single-revision, remote-only source: no local `bundle_path`. Fetch the
794        // artifact from `bundle_source_uri` so it stages exactly like a local
795        // bundle, then verify any declared digest. (Multi-revision remote-only
796        // is a follow-up; `validate_shape` permits URI-only for the
797        // single-revision form only.)
798        if b.bundle_path.is_none() && b.revisions.is_none() {
799            let uri = b
800                .bundle_source_uri
801                .as_deref()
802                .expect("validate_shape: single-revision URI-only carries bundle_source_uri");
803            let fetched = match super::bundle_fetch::fetch_bundle_uri_to_local(uri) {
804                Ok(path) => path,
805                // Unreachable registry / missing artifact is an input gap,
806                // reported like an absent local file (skippable, not fatal).
807                Err(OpError::Fetch(message)) => {
808                    missing.push(MissingItem {
809                        kind: MissingKind::BundleArtifact,
810                        key: b.bundle_id.clone(),
811                        source: format!("uri:{uri} ({message})"),
812                    });
813                    continue;
814                }
815                // A malformed / unsupported URI is a manifest bug — fail fast.
816                Err(other) => return Err(other),
817            };
818            let digest = resolved_artifact_digest(
819                &fetched,
820                b.bundle_digest.as_deref(),
821                &format!("bundle `{}`", b.bundle_id),
822            )?;
823            resolved_bundles.push(ResolvedBundle {
824                spec: b.clone(),
825                customer_id,
826                revisions: vec![ResolvedRevision {
827                    spec: ManifestRevision {
828                        name: "default".to_string(),
829                        // Vestigial: `deploy_payload` reads `resolved_path`, not
830                        // this. The fetched cache file is the local location.
831                        bundle_path: fetched.clone(),
832                        weight_percent: None,
833                        weight_bps: Some(super::deploy::FULL_TRAFFIC_BPS),
834                        drain_seconds: None,
835                        abort_metrics: Vec::new(),
836                        // The single-revision pull ref lives on the bundle
837                        // (`rb.spec.bundle_source_uri`, read by `deploy_payload`).
838                        bundle_source_uri: None,
839                        bundle_digest: None,
840                    },
841                    resolved_path: fetched,
842                    digest,
843                    weight_bps: super::deploy::FULL_TRAFFIC_BPS,
844                }],
845            });
846            continue;
847        }
848
849        // Resolve each artifact path (single-revision or multi-revision).
850        let artifact_specs: Vec<(&std::path::Path, Option<&ManifestRevision>)> =
851            if let Some(bp) = &b.bundle_path {
852                vec![(bp.as_path(), None)]
853            } else if let Some(revs) = &b.revisions {
854                revs.iter()
855                    .map(|r| (r.bundle_path.as_path(), Some(r)))
856                    .collect()
857            } else {
858                // validate_shape already rejects this, but be safe.
859                continue;
860            };
861
862        let mut any_missing = false;
863        let mut resolved_revs = Vec::with_capacity(artifact_specs.len());
864
865        // For multi-revision, compute effective weights (validated by
866        // validate_shape). Single-revision always gets FULL_TRAFFIC_BPS.
867        let weights: Vec<u32> = if let Some(revs) = &b.revisions {
868            compute_effective_weights_bps(revs)
869        } else {
870            vec![super::deploy::FULL_TRAFFIC_BPS]
871        };
872
873        for (i, (artifact_path, rev_spec)) in artifact_specs.iter().enumerate() {
874            let resolved_path = if artifact_path.is_absolute() {
875                artifact_path.to_path_buf()
876            } else {
877                manifest_dir.join(artifact_path)
878            };
879            if !resolved_path.is_file() {
880                let key = match rev_spec {
881                    Some(r) => format!("{}:{}", b.bundle_id, r.name),
882                    None => b.bundle_id.clone(),
883                };
884                missing.push(MissingItem {
885                    kind: MissingKind::BundleArtifact,
886                    key,
887                    source: format!("path:{}", resolved_path.display()),
888                });
889                any_missing = true;
890                continue;
891            }
892            let declared_digest = match rev_spec {
893                Some(r) => r.bundle_digest.as_deref(),
894                None => b.bundle_digest.as_deref(),
895            };
896            let location = match rev_spec {
897                Some(r) => format!("bundle `{}`, revision `{}`", b.bundle_id, r.name),
898                None => format!("bundle `{}`", b.bundle_id),
899            };
900            let digest = resolved_artifact_digest(&resolved_path, declared_digest, &location)?;
901            let spec = rev_spec.cloned().unwrap_or_else(|| {
902                // Synthesize a ManifestRevision for single-revision entries.
903                ManifestRevision {
904                    name: "default".to_string(),
905                    bundle_path: b
906                        .bundle_path
907                        .clone()
908                        .expect("single-revision has bundle_path"),
909                    weight_percent: None,
910                    weight_bps: Some(super::deploy::FULL_TRAFFIC_BPS),
911                    drain_seconds: None,
912                    abort_metrics: Vec::new(),
913                    // Single-revision pull ref lives on the bundle
914                    // (`rb.spec.bundle_source_uri`, read by `deploy_payload`),
915                    // not on this synthetic revision.
916                    bundle_source_uri: None,
917                    bundle_digest: None,
918                }
919            });
920            resolved_revs.push(ResolvedRevision {
921                spec,
922                resolved_path,
923                digest,
924                weight_bps: weights[i],
925            });
926        }
927
928        if any_missing {
929            continue;
930        }
931
932        resolved_bundles.push(ResolvedBundle {
933            spec: b.clone(),
934            customer_id,
935            revisions: resolved_revs,
936        });
937    }
938
939    // Deployment natural-key sanity: `op deploy` keys on
940    // `(bundle_id, customer_id)`. Refuse to adopt a deployment owned by a
941    // different billing principal, and reject same-pair ambiguity.
942    if let Some(env) = &env {
943        for rb in &resolved_bundles {
944            let same_customer: Vec<_> = env
945                .bundles
946                .iter()
947                .filter(|d| {
948                    d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id
949                })
950                .collect();
951            let other_customer: Vec<_> = env
952                .bundles
953                .iter()
954                .filter(|d| {
955                    d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id != rb.customer_id
956                })
957                .collect();
958            if !other_customer.is_empty() {
959                return Err(OpError::Conflict(format!(
960                    "bundle `{}`: deployment owned by customer `{}` exists but manifest \
961                     resolves to `{}` — apply refuses to adopt a deployment owned by a \
962                     different customer",
963                    rb.spec.bundle_id,
964                    other_customer[0].customer_id.as_str(),
965                    rb.customer_id.as_str(),
966                )));
967            }
968            if same_customer.len() > 1 {
969                return Err(OpError::Conflict(format!(
970                    "bundle `{}` matches {} deployments for customer `{}` in env `{env_id}` \
971                     — apply refuses to guess; reconcile with `gtc op bundles list {env_id}` \
972                     first",
973                    rb.spec.bundle_id,
974                    same_customer.len(),
975                    rb.customer_id.as_str(),
976                )));
977            }
978        }
979    }
980
981    let mut warnings = Vec::new();
982
983    // Endpoint link targets + match ambiguity + welcome-flow reachability.
984    let mut resolved_endpoints = Vec::with_capacity(manifest.messaging_endpoints.len());
985    for ep in &manifest.messaging_endpoints {
986        for link in &ep.links {
987            let in_manifest = manifest.bundles.iter().any(|b| &b.bundle_id == link);
988            if in_manifest {
989                continue;
990            }
991            let in_env = env
992                .as_ref()
993                .is_some_and(|e| e.bundles.iter().any(|d| d.bundle_id.as_str() == link));
994            if in_env {
995                warnings.push(format!(
996                    "endpoint `{}`: link `{link}` is satisfied only by a pre-existing env \
997                     deployment (not declared in this manifest)",
998                    ep.name
999                ));
1000            } else {
1001                return Err(OpError::InvalidArgument(format!(
1002                    "endpoint `{}`: link `{link}` is neither declared in this manifest's \
1003                     bundles[] nor deployed in env `{env_id}`",
1004                    ep.name
1005                )));
1006            }
1007        }
1008
1009        let matched = match &env {
1010            Some(e) => match_existing_endpoint(e, ep)?,
1011            None => None,
1012        };
1013        if let Some(wf) = &ep.welcome_flow {
1014            let linked_via_manifest = ep.links.contains(&wf.bundle_id);
1015            let linked_on_existing = matched
1016                .is_some_and(|m| m.linked_bundles.iter().any(|b| b.as_str() == wf.bundle_id));
1017            if !linked_via_manifest && !linked_on_existing {
1018                return Err(OpError::InvalidArgument(format!(
1019                    "endpoint `{}`: welcome_flow.bundle_id `{}` must be in this endpoint's \
1020                     links[] (or already linked on the matched endpoint)",
1021                    ep.name, wf.bundle_id
1022                )));
1023            }
1024        }
1025        if let Some(m) = matched {
1026            let existing_refs: Vec<String> = m
1027                .secret_refs
1028                .iter()
1029                .map(|r| r.as_str().to_string())
1030                .collect();
1031            if !ep.secret_refs.is_empty() && ep.secret_refs != existing_refs {
1032                warnings.push(format!(
1033                    "endpoint `{}`: secret_refs differ from the existing endpoint's — \
1034                     left untouched (no endpoint update verb exists yet)",
1035                    ep.name
1036                ));
1037            }
1038        }
1039        resolved_endpoints.push(ResolvedEndpoint {
1040            spec: ep.clone(),
1041            matched: matched.cloned(),
1042        });
1043    }
1044
1045    // Validate that every declared `answers_ref` resolves to an existing file
1046    // relative to the manifest directory. The schema contract says "relative to
1047    // the manifest", so catch missing files at plan time rather than letting a
1048    // later `op env render` fail with a confusing path error.
1049    // NOTE: the stored path is the raw manifest-relative value. The consumer
1050    // (`load_render_answers`) resolves relative to the *env* directory, which
1051    // may differ. A future iteration should copy the file into the env dir or
1052    // store an env-relative path; for now we at least fail fast on a typo.
1053    for p in &manifest.packs {
1054        if let Some(ar) = &p.answers_ref {
1055            let resolved = if ar.is_absolute() {
1056                ar.clone()
1057            } else {
1058                manifest_dir.join(ar)
1059            };
1060            if !resolved.is_file() {
1061                return Err(OpError::InvalidArgument(format!(
1062                    "packs[] slot `{}`: answers_ref `{}` does not exist (resolved to `{}`)",
1063                    p.slot,
1064                    ar.display(),
1065                    resolved.display()
1066                )));
1067            }
1068        }
1069    }
1070    for ext in &manifest.extensions {
1071        if let Some(ar) = &ext.answers_ref {
1072            let resolved = if ar.is_absolute() {
1073                ar.clone()
1074            } else {
1075                manifest_dir.join(ar)
1076            };
1077            if !resolved.is_file() {
1078                return Err(OpError::InvalidArgument(format!(
1079                    "extensions[] kind `{}`: answers_ref `{}` does not exist (resolved to `{}`)",
1080                    ext.kind,
1081                    ar.display(),
1082                    resolved.display()
1083                )));
1084            }
1085        }
1086    }
1087
1088    Ok(ApplyContext {
1089        env_id,
1090        manifest,
1091        secret_values,
1092        prompted_paths,
1093        store_satisfied_paths,
1094        bundles: resolved_bundles,
1095        endpoints: resolved_endpoints,
1096        env,
1097        canonical_public_base_url,
1098        missing,
1099        warnings,
1100        updated_by,
1101        manifest_dir: manifest_dir.to_path_buf(),
1102    })
1103}
1104
1105/// Match an existing endpoint by the `(provider_type, display_name)` natural
1106/// key. More than one pair match = ambiguity error; a display-name match
1107/// with a different `provider_type` = repurposed-name error (apply refuses
1108/// to guess).
1109fn match_existing_endpoint<'e>(
1110    env: &'e Environment,
1111    ep: &ManifestEndpoint,
1112) -> Result<Option<&'e MessagingEndpoint>, OpError> {
1113    let name_matches: Vec<&MessagingEndpoint> = env
1114        .messaging_endpoints
1115        .iter()
1116        .filter(|m| m.display_name == ep.name)
1117        .collect();
1118    let pair_matches: Vec<&&MessagingEndpoint> = name_matches
1119        .iter()
1120        .filter(|m| m.provider_type == ep.provider_type)
1121        .collect();
1122    match pair_matches.len() {
1123        0 if name_matches.is_empty() => Ok(None),
1124        0 => Err(OpError::Conflict(format!(
1125            "endpoint `{}`: an endpoint with this display_name exists but with \
1126             provider_type `{}` (manifest says `{}`) — apply refuses to repurpose a name; \
1127             rename or remove the existing endpoint first",
1128            ep.name, name_matches[0].provider_type, ep.provider_type
1129        ))),
1130        1 => Ok(Some(pair_matches[0])),
1131        n => Err(OpError::Conflict(format!(
1132            "endpoint `{}`: {n} existing endpoints match (provider_type=`{}`, \
1133             display_name=`{}`): [{}] — apply refuses to guess; remove the duplicates first",
1134            ep.name,
1135            ep.provider_type,
1136            ep.name,
1137            pair_matches
1138                .iter()
1139                .map(|m| m.endpoint_id.to_string())
1140                .collect::<Vec<_>>()
1141                .join(", ")
1142        ))),
1143    }
1144}
1145
1146// --- diff -----------------------------------------------------------------------
1147
1148fn diff(store: &LocalFsStore, ctx: &ApplyContext) -> Result<Vec<ApplyStep>, OpError> {
1149    let mut steps = Vec::new();
1150    let env_id_str = ctx.env_id.as_str().to_string();
1151
1152    // 1. EnsureEnvironment. `ctx.env == None` only ever reaches here for the
1153    //    `local` env — `resolve_and_validate` rejects a non-existent non-local
1154    //    env before diffing (non-local creation is reserved for the remote
1155    //    operator store, A7).
1156    match &ctx.env {
1157        None => {
1158            steps.push(ApplyStep {
1159                kind: ApplyStepKind::EnsureEnvironment,
1160                key: env_id_str.clone(),
1161                action: ApplyAction::Create,
1162                detail: "env init (local bootstrap: default env-pack bindings + trust-root seed)"
1163                    .to_string(),
1164                idempotency_key: None,
1165                op: StepOp::EnvInit {
1166                    public_base_url: ctx.canonical_public_base_url.clone(),
1167                },
1168            });
1169        }
1170        Some(env) => match &ctx.canonical_public_base_url {
1171            Some(url) if env.host_config.public_base_url.as_deref() != Some(url.as_str()) => {
1172                steps.push(ApplyStep {
1173                    kind: ApplyStepKind::EnsureEnvironment,
1174                    key: env_id_str.clone(),
1175                    action: ApplyAction::Update,
1176                    detail: format!("set-public-url {url}"),
1177                    idempotency_key: None,
1178                    op: StepOp::SetPublicUrl { url: url.clone() },
1179                });
1180            }
1181            _ => steps.push(ApplyStep::no_op(
1182                ApplyStepKind::EnsureEnvironment,
1183                env_id_str.clone(),
1184                "exists (public_base_url unchanged)",
1185            )),
1186        },
1187    }
1188
1189    // 2. UpdateHostConfig (name, region, tenant_org_id, listen_addr, gui_enabled).
1190    //    Compares declared manifest fields against the live env; any declared
1191    //    field that differs emits ONE UpdateHostConfig step. On a fresh env
1192    //    (env == None) the host-config update is deferred to after the
1193    //    EnsureEnvironment execute creates the env.
1194    if let Some(env) = &ctx.env {
1195        let me = &ctx.manifest.environment;
1196        let name_differs = me.name.as_ref().is_some_and(|n| *n != env.name);
1197        let region_differs = me
1198            .region
1199            .as_ref()
1200            .is_some_and(|r| env.host_config.region.as_deref() != Some(r.as_str()));
1201        let tenant_org_differs = me
1202            .tenant_org_id
1203            .as_ref()
1204            .is_some_and(|t| env.host_config.tenant_org_id.as_deref() != Some(t.as_str()));
1205        let listen_addr_differs = me.listen_addr.as_ref().is_some_and(|la| {
1206            let parsed: std::net::SocketAddr = la
1207                .parse()
1208                .expect("validate_shape already validated listen_addr");
1209            env.host_config.listen_addr != Some(parsed)
1210        });
1211        // Compares against the RAW stored value (like every other host-config
1212        // field) — declaring a value always persists it; the env-id default
1213        // (on for local) is resolved by the runtime only when unset.
1214        let gui_enabled_differs = me
1215            .gui_enabled
1216            .is_some_and(|g| env.host_config.gui_enabled != Some(g));
1217
1218        if name_differs
1219            || region_differs
1220            || tenant_org_differs
1221            || listen_addr_differs
1222            || gui_enabled_differs
1223        {
1224            let mut fields = Vec::new();
1225            if name_differs {
1226                fields.push("name");
1227            }
1228            if region_differs {
1229                fields.push("region");
1230            }
1231            if tenant_org_differs {
1232                fields.push("tenant_org_id");
1233            }
1234            if listen_addr_differs {
1235                fields.push("listen_addr");
1236            }
1237            if gui_enabled_differs {
1238                fields.push("gui_enabled");
1239            }
1240            let desired_hash = hash_json(&json!({
1241                "name": me.name,
1242                "region": me.region,
1243                "tenant_org_id": me.tenant_org_id,
1244                "listen_addr": me.listen_addr,
1245                "gui_enabled": me.gui_enabled,
1246            }));
1247            let ikey = derive_idempotency_key(
1248                &ctx.env_id,
1249                ApplyStepKind::UpdateHostConfig.label(),
1250                &env_id_str,
1251                &desired_hash,
1252            );
1253            steps.push(ApplyStep {
1254                kind: ApplyStepKind::UpdateHostConfig,
1255                key: env_id_str.clone(),
1256                action: ApplyAction::Update,
1257                detail: format!("set {}", fields.join(", ")),
1258                idempotency_key: Some(ikey),
1259                op: StepOp::UpdateHostConfig(Box::new(ConfigSetPayload {
1260                    environment_id: env_id_str.clone(),
1261                    name: me.name.clone(),
1262                    region: me.region.clone(),
1263                    tenant_org_id: me.tenant_org_id.clone(),
1264                    listen_addr: me.listen_addr.clone(),
1265                    public_base_url: None, // public_base_url is handled by SetPublicUrl
1266                    gui_enabled: me.gui_enabled,
1267                })),
1268            });
1269        } else if me.declares_host_config() {
1270            steps.push(ApplyStep::no_op(
1271                ApplyStepKind::UpdateHostConfig,
1272                env_id_str.clone(),
1273                "host-config unchanged",
1274            ));
1275        }
1276    } else {
1277        // Fresh env (always `local` here — see EnsureEnvironment). `env init`
1278        // does NOT thread name/region/tenant_org_id/listen_addr, so a declared
1279        // host-config is applied by a deferred UpdateHostConfig step that runs
1280        // after the env is created.
1281        let me = &ctx.manifest.environment;
1282        if me.declares_host_config() {
1283            steps.push(ApplyStep {
1284                kind: ApplyStepKind::UpdateHostConfig,
1285                key: env_id_str.clone(),
1286                action: ApplyAction::Create,
1287                detail: "set host-config on fresh env".to_string(),
1288                idempotency_key: None,
1289                op: StepOp::UpdateHostConfig(Box::new(ConfigSetPayload {
1290                    environment_id: env_id_str.clone(),
1291                    name: me.name.clone(),
1292                    region: me.region.clone(),
1293                    tenant_org_id: me.tenant_org_id.clone(),
1294                    listen_addr: me.listen_addr.clone(),
1295                    public_base_url: None,
1296                    gui_enabled: me.gui_enabled,
1297                })),
1298            });
1299        }
1300    }
1301
1302    // 3. BootstrapTrustRoot.
1303    if ctx.manifest.trust_root == Some(TrustRootDirective::Bootstrap) {
1304        steps.push(trust_root_step(store, ctx)?);
1305    }
1306
1307    // 4. Env-packs — each manifest pack binds one core capability slot.
1308    //    Look up the existing binding by slot; absent → add, differs → update.
1309    //
1310    //    On a fresh env (`ctx.env == None`, always `local` here) the preceding
1311    //    EnsureEnvironment step will create the env with five default bindings
1312    //    (deployer, secrets, telemetry, sessions, state). We must diff against
1313    //    those defaults so packs targeting a default slot emit UpdatePackBinding
1314    //    (or no-op) instead of an unconditional AddPackBinding that would fail
1315    //    with `SlotAlreadyBound` after `env init` runs.
1316    let default_bindings = if ctx.env.is_none() {
1317        Some(
1318            crate::defaults::local_pack_bindings()
1319                .map_err(|e| OpError::InvalidArgument(format!("default pack bindings: {e}")))?,
1320        )
1321    } else {
1322        None
1323    };
1324    for mp in &ctx.manifest.packs {
1325        let existing = match &ctx.env {
1326            Some(e) => e.pack_for_slot(mp.slot),
1327            None => default_bindings
1328                .as_ref()
1329                .and_then(|bs| bs.iter().find(|b| b.slot == mp.slot)),
1330        };
1331        match existing {
1332            None => {
1333                let desired_hash = hash_json(&json!({
1334                    "slot": mp.slot.to_string(),
1335                    "kind": mp.kind,
1336                    "pack_ref": mp.pack_ref,
1337                    "answers_ref": mp.answers_ref,
1338                }));
1339                let ikey = derive_idempotency_key(
1340                    &ctx.env_id,
1341                    ApplyStepKind::AddPackBinding.label(),
1342                    &mp.slot.to_string(),
1343                    &desired_hash,
1344                );
1345                steps.push(ApplyStep {
1346                    kind: ApplyStepKind::AddPackBinding,
1347                    key: mp.slot.to_string(),
1348                    action: ApplyAction::Create,
1349                    detail: format!("{} ({})", mp.kind, mp.pack_ref),
1350                    idempotency_key: Some(ikey.clone()),
1351                    op: StepOp::AddPackBinding(Box::new(EnvPackBindingPayload {
1352                        environment_id: env_id_str.clone(),
1353                        slot: mp.slot,
1354                        kind: mp.kind.clone(),
1355                        pack_ref: mp.pack_ref.clone(),
1356                        answers_ref: mp.answers_ref.clone(),
1357                        idempotency_key: Some(ikey),
1358                    })),
1359                });
1360            }
1361            Some(b) => {
1362                let kind_differs = b.kind.to_string() != mp.kind;
1363                let pack_ref_differs = b.pack_ref.as_str() != mp.pack_ref;
1364                // Content-aware + ref-aware: the stored ref is the env-relative
1365                // staged path (rewritten on apply), so a raw string compare
1366                // against the manifest-relative ref would always differ.
1367                // Compare the staged file's bytes to the manifest source AND
1368                // verify the binding's `answers_ref` points at the canonical
1369                // staged path — a prior interrupted apply could have staged the
1370                // file but failed to persist the ref on the binding, so checking
1371                // bytes alone would miss that drift. (A manifest that drops
1372                // answers_ref is left as-is here, matching the prior behaviour.)
1373                let answers_ref_differs = match &mp.answers_ref {
1374                    Some(ar) => {
1375                        let content_outdated = staged_answers_outdated(
1376                            store,
1377                            &ctx.env_id,
1378                            &ctx.manifest_dir,
1379                            mp.slot,
1380                            ar,
1381                        )?;
1382                        let ref_wrong =
1383                            b.answers_ref.as_deref() != Some(staged_answers_rel(mp.slot).as_path());
1384                        content_outdated || ref_wrong
1385                    }
1386                    None => false,
1387                };
1388                if kind_differs || pack_ref_differs || answers_ref_differs {
1389                    let desired_hash = hash_json(&json!({
1390                        "slot": mp.slot.to_string(),
1391                        "kind": mp.kind,
1392                        "pack_ref": mp.pack_ref,
1393                        "answers_ref": mp.answers_ref,
1394                    }));
1395                    let ikey = derive_idempotency_key(
1396                        &ctx.env_id,
1397                        ApplyStepKind::UpdatePackBinding.label(),
1398                        &mp.slot.to_string(),
1399                        &desired_hash,
1400                    );
1401                    let mut what = Vec::new();
1402                    if kind_differs {
1403                        what.push(format!("kind → {}", mp.kind));
1404                    }
1405                    if pack_ref_differs {
1406                        what.push(format!("pack_ref → {}", mp.pack_ref));
1407                    }
1408                    if answers_ref_differs {
1409                        what.push("answers_ref".to_string());
1410                    }
1411                    steps.push(ApplyStep {
1412                        kind: ApplyStepKind::UpdatePackBinding,
1413                        key: mp.slot.to_string(),
1414                        action: ApplyAction::Update,
1415                        detail: what.join(", "),
1416                        idempotency_key: Some(ikey.clone()),
1417                        op: StepOp::UpdatePackBinding(Box::new(EnvPackBindingPayload {
1418                            environment_id: env_id_str.clone(),
1419                            slot: mp.slot,
1420                            kind: mp.kind.clone(),
1421                            pack_ref: mp.pack_ref.clone(),
1422                            answers_ref: mp.answers_ref.clone(),
1423                            idempotency_key: Some(ikey),
1424                        })),
1425                    });
1426                } else {
1427                    steps.push(ApplyStep::no_op(
1428                        ApplyStepKind::AddPackBinding,
1429                        mp.slot.to_string(),
1430                        format!("bound ({}, {})", mp.kind, mp.pack_ref),
1431                    ));
1432                }
1433            }
1434        }
1435    }
1436
1437    // 5. Secrets — always-put: `op secrets get` is not-yet-implemented for
1438    //    every backend, so values cannot be diffed. The plan says so
1439    //    explicitly rather than ever claiming a false no-op; when A9 lands a
1440    //    real `get`, this tightens to write-if-changed with no schema
1441    //    change. Secrets land before bundles so a just-deployed revision
1442    //    never serves a request that resolves a missing secret.
1443    for s in &ctx.manifest.secrets {
1444        // A paste-sourced secret already in the store needs no put step
1445        // (store-as-source-of-truth). A missing-input secret still emits an
1446        // undiffable put step for plan visibility — it never reaches execute
1447        // because the mutating path aborts on missing inputs first, exactly
1448        // as for an unset env var.
1449        if ctx.store_satisfied_paths.contains(&s.path) {
1450            continue;
1451        }
1452        // Deliberately NO deterministic idempotency key: an always-put is
1453        // by definition a NEW write (values cannot be diffed until A9), so
1454        // a value-insensitive key would stamp two semantically different
1455        // writes (e.g. a secret rotation under the same env var) with the
1456        // same key — conflating audit records and wrongly replaying under
1457        // any future same-key dedupe layer (A8 same-key-different-body
1458        // conflict rule). `secrets::put` mints a fresh per-invocation key
1459        // instead (second exception alongside deploy's per-revision
1460        // cut-over key).
1461        let detail = match &s.from_env {
1462            None => "pasted (cannot diff until A9)".to_string(),
1463            Some(_) if ctx.prompted_paths.contains(&s.path) => {
1464                "prompted (cannot diff until A9)".to_string()
1465            }
1466            Some(from_env) => format!("from ${from_env} (cannot diff until A9)"),
1467        };
1468        steps.push(ApplyStep {
1469            kind: ApplyStepKind::PutSecret,
1470            key: s.path.clone(),
1471            action: ApplyAction::Put,
1472            detail,
1473            idempotency_key: None,
1474            op: StepOp::PutSecret {
1475                path: s.path.clone(),
1476            },
1477        });
1478    }
1479
1480    // 4. Bundles (keyed on `(bundle_id, customer_id)` — the same natural key
1481    //    that `op deploy` uses).
1482    for rb in &ctx.bundles {
1483        let is_multi = rb.spec.revisions.is_some();
1484        let existing = ctx.env.as_ref().and_then(|e| {
1485            e.bundles.iter().find(|d| {
1486                d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id
1487            })
1488        });
1489
1490        // Helper: the primary digest for single-revision entries (first
1491        // and only element).
1492        let primary_digest = || rb.revisions[0].digest.clone();
1493
1494        match existing {
1495            None if is_multi => {
1496                let digests: Vec<String> = rb
1497                    .revisions
1498                    .iter()
1499                    .map(|r| format!("{}@{}", r.spec.name, short_digest(&r.digest)))
1500                    .collect();
1501                let detail = format!(
1502                    "{} revision(s) [{}] → {}",
1503                    rb.revisions.len(),
1504                    digests.join(", "),
1505                    binding_summary(&rb.spec.route_binding.clone().map(into_route_binding))
1506                );
1507                steps.push(ApplyStep {
1508                    kind: ApplyStepKind::DeploySplit,
1509                    key: rb.spec.bundle_id.clone(),
1510                    action: ApplyAction::Create,
1511                    detail,
1512                    idempotency_key: None,
1513                    op: deploy_split_op(&env_id_str, rb),
1514                });
1515            }
1516            None => {
1517                let detail = format!(
1518                    "{} → {}",
1519                    short_digest(&primary_digest()),
1520                    binding_summary(&rb.spec.route_binding.clone().map(into_route_binding))
1521                );
1522                steps.push(ApplyStep {
1523                    kind: ApplyStepKind::DeployBundle,
1524                    key: rb.spec.bundle_id.clone(),
1525                    action: ApplyAction::Create,
1526                    detail,
1527                    idempotency_key: None, // deploy derives its cut-over key per revision
1528                    op: StepOp::Deploy {
1529                        payload: Box::new(deploy_payload(
1530                            &env_id_str,
1531                            rb,
1532                            rb.spec.route_binding.clone(),
1533                        )),
1534                        expected_digest: primary_digest(),
1535                    },
1536                });
1537            }
1538            Some(dep) if is_multi => {
1539                let env = ctx.env.as_ref().expect("existing deployment implies env");
1540                let converged = split_converged(env, dep.deployment_id, &rb.revisions);
1541                let desired_binding: Option<RouteBinding> =
1542                    rb.spec.route_binding.clone().map(into_route_binding);
1543                let binding_differs = desired_binding
1544                    .as_ref()
1545                    .is_some_and(|b| *b != dep.route_binding);
1546                let overrides_differ = rb
1547                    .spec
1548                    .config_overrides
1549                    .as_ref()
1550                    .is_some_and(|o| *o != dep.config_overrides);
1551
1552                if !converged {
1553                    let digests: Vec<String> = rb
1554                        .revisions
1555                        .iter()
1556                        .map(|r| format!("{}@{}", r.spec.name, short_digest(&r.digest)))
1557                        .collect();
1558                    let detail = format!(
1559                        "split not converged → re-deploy {} revision(s) [{}]",
1560                        rb.revisions.len(),
1561                        digests.join(", ")
1562                    );
1563                    steps.push(ApplyStep {
1564                        kind: ApplyStepKind::DeploySplit,
1565                        key: rb.spec.bundle_id.clone(),
1566                        action: ApplyAction::Update,
1567                        detail,
1568                        idempotency_key: None,
1569                        op: deploy_split_op(&env_id_str, rb),
1570                    });
1571                }
1572
1573                // Metadata reconcile. `config_overrides` defers to a converged
1574                // split (it otherwise rides the next re-split run); revenue_
1575                // share/status are deployment-level and reconcile independently
1576                // of the split (a re-split never touches them).
1577                let diff = BundleMetaDiff {
1578                    route_binding: binding_differs.then(|| {
1579                        rb.spec
1580                            .route_binding
1581                            .clone()
1582                            .expect("binding_differs implies manifest binding")
1583                    }),
1584                    config_overrides: (overrides_differ && converged)
1585                        .then(|| rb.spec.config_overrides.clone().expect("overrides_differ")),
1586                    revenue_share: revenue_share_differs(rb, dep).then(|| {
1587                        rb.spec
1588                            .revenue_share
1589                            .clone()
1590                            .expect("differs implies manifest")
1591                    }),
1592                    status: status_differs(rb, dep).then(|| rb.spec.status.expect("differs")),
1593                };
1594                let did_update = !diff.is_noop();
1595                if let Some(step) = bundle_meta_update_step(
1596                    &ctx.env_id,
1597                    &env_id_str,
1598                    &rb.spec.bundle_id,
1599                    dep.deployment_id,
1600                    &desired_binding,
1601                    diff,
1602                ) {
1603                    steps.push(step);
1604                }
1605
1606                if converged && !did_update {
1607                    steps.push(ApplyStep::no_op(
1608                        ApplyStepKind::DeploySplit,
1609                        rb.spec.bundle_id.clone(),
1610                        format!("split converged ({} revision(s))", rb.revisions.len()),
1611                    ));
1612                }
1613            }
1614            Some(dep) => {
1615                let desired_binding: Option<RouteBinding> =
1616                    rb.spec.route_binding.clone().map(into_route_binding);
1617                let binding_differs = desired_binding
1618                    .as_ref()
1619                    .is_some_and(|b| *b != dep.route_binding);
1620                let overrides_differ = rb
1621                    .spec
1622                    .config_overrides
1623                    .as_ref()
1624                    .is_some_and(|o| *o != dep.config_overrides);
1625                let env = ctx.env.as_ref().expect("existing deployment implies env");
1626                let converged = deployment_converged(
1627                    env,
1628                    dep.deployment_id,
1629                    &primary_digest(),
1630                    rb.spec.bundle_source_uri.as_deref(),
1631                );
1632                let needs_deploy = !converged;
1633                let live = live_revision_digest(env, dep.deployment_id);
1634
1635                // Deploy FIRST when re-staging is needed (routing-metadata-
1636                // last: a stage failure leaves the OLD revision serving under
1637                // the OLD binding, keeping published paths alive). The
1638                // binding/overrides update runs AFTER the deploy lands.
1639                if needs_deploy {
1640                    // Fix 1: two arms — real live digest vs degenerate/missing.
1641                    let detail = if live.is_some_and(digest_is_real) {
1642                        format!(
1643                            "digest {} → {} (blue-green re-stage)",
1644                            live.map(short_digest).unwrap_or("none"),
1645                            short_digest(&primary_digest())
1646                        )
1647                    } else {
1648                        format!(
1649                            "traffic split is not a single 100% entry \
1650                             → re-deploy reconverges ({})",
1651                            short_digest(&primary_digest())
1652                        )
1653                    };
1654                    steps.push(ApplyStep {
1655                        kind: ApplyStepKind::DeployBundle,
1656                        key: rb.spec.bundle_id.clone(),
1657                        action: ApplyAction::Update,
1658                        detail,
1659                        idempotency_key: None,
1660                        op: StepOp::Deploy {
1661                            // Overrides ride the deploy (applied after
1662                            // stage+warm, before the cut-over). The
1663                            // binding is reconciled by the update step
1664                            // AFTER the deploy lands, so None here —
1665                            // deploy rejects a differing binding.
1666                            payload: Box::new(deploy_payload(&env_id_str, rb, None)),
1667                            expected_digest: primary_digest(),
1668                        },
1669                    });
1670                }
1671
1672                // Metadata reconcile. `config_overrides` ride the deploy when
1673                // re-staging (so they defer here while `needs_deploy`); the
1674                // binding is applied AFTER the deploy lands; revenue_share/
1675                // status are deployment-level and reconcile independently of
1676                // the deploy (a re-deploy never resets them).
1677                let diff = BundleMetaDiff {
1678                    route_binding: binding_differs.then(|| {
1679                        rb.spec
1680                            .route_binding
1681                            .clone()
1682                            .expect("binding_differs implies manifest binding")
1683                    }),
1684                    config_overrides: (overrides_differ && !needs_deploy)
1685                        .then(|| rb.spec.config_overrides.clone().expect("overrides_differ")),
1686                    revenue_share: revenue_share_differs(rb, dep).then(|| {
1687                        rb.spec
1688                            .revenue_share
1689                            .clone()
1690                            .expect("differs implies manifest")
1691                    }),
1692                    status: status_differs(rb, dep).then(|| rb.spec.status.expect("differs")),
1693                };
1694                let did_update = !diff.is_noop();
1695                if let Some(step) = bundle_meta_update_step(
1696                    &ctx.env_id,
1697                    &env_id_str,
1698                    &rb.spec.bundle_id,
1699                    dep.deployment_id,
1700                    &desired_binding,
1701                    diff,
1702                ) {
1703                    steps.push(step);
1704                }
1705
1706                if !needs_deploy && !did_update {
1707                    steps.push(ApplyStep::no_op(
1708                        ApplyStepKind::DeployBundle,
1709                        rb.spec.bundle_id.clone(),
1710                        format!("digest match ({})", short_digest(&primary_digest())),
1711                    ));
1712                }
1713            }
1714        }
1715    }
1716
1717    // 8. Extensions — N-per-env open namespace, keyed by (kind.path(), instance_id).
1718    for mx in &ctx.manifest.extensions {
1719        let kind_path = greentic_deploy_spec::PackDescriptor::try_new(&mx.kind)
1720            .expect("validate_shape already validated kind")
1721            .path()
1722            .to_string();
1723        let ext_key = format!(
1724            "{}{}",
1725            kind_path,
1726            mx.instance_id
1727                .as_ref()
1728                .map(|i| format!("/{i}"))
1729                .unwrap_or_default()
1730        );
1731        let existing = ctx.env.as_ref().and_then(|e| {
1732            e.extensions
1733                .iter()
1734                .find(|b| b.kind.path() == kind_path && b.instance_id == mx.instance_id)
1735        });
1736        match existing {
1737            None => {
1738                let desired_hash = hash_json(&json!({
1739                    "kind": mx.kind,
1740                    "pack_ref": mx.pack_ref,
1741                    "instance_id": mx.instance_id,
1742                    "answers_ref": mx.answers_ref,
1743                }));
1744                let ikey = derive_idempotency_key(
1745                    &ctx.env_id,
1746                    ApplyStepKind::AddExtension.label(),
1747                    &ext_key,
1748                    &desired_hash,
1749                );
1750                steps.push(ApplyStep {
1751                    kind: ApplyStepKind::AddExtension,
1752                    key: ext_key,
1753                    action: ApplyAction::Create,
1754                    detail: format!("{} ({})", mx.kind, mx.pack_ref),
1755                    idempotency_key: Some(ikey.clone()),
1756                    op: StepOp::AddExtension(Box::new(ExtensionBindingPayload {
1757                        environment_id: env_id_str.clone(),
1758                        kind: mx.kind.clone(),
1759                        pack_ref: mx.pack_ref.clone(),
1760                        instance_id: mx.instance_id.clone(),
1761                        answers_ref: mx.answers_ref.clone(),
1762                        idempotency_key: Some(ikey),
1763                    })),
1764                });
1765            }
1766            Some(b) => {
1767                let kind_differs = b.kind.to_string() != mx.kind;
1768                let pack_ref_differs = b.pack_ref.as_str() != mx.pack_ref;
1769                let answers_ref_differs = mx
1770                    .answers_ref
1771                    .as_ref()
1772                    .is_some_and(|ar| b.answers_ref.as_ref() != Some(ar));
1773                if kind_differs || pack_ref_differs || answers_ref_differs {
1774                    let desired_hash = hash_json(&json!({
1775                        "kind": mx.kind,
1776                        "pack_ref": mx.pack_ref,
1777                        "instance_id": mx.instance_id,
1778                        "answers_ref": mx.answers_ref,
1779                    }));
1780                    let ikey = derive_idempotency_key(
1781                        &ctx.env_id,
1782                        ApplyStepKind::UpdateExtension.label(),
1783                        &ext_key,
1784                        &desired_hash,
1785                    );
1786                    let mut what = Vec::new();
1787                    if kind_differs {
1788                        what.push(format!("kind → {}", mx.kind));
1789                    }
1790                    if pack_ref_differs {
1791                        what.push(format!("pack_ref → {}", mx.pack_ref));
1792                    }
1793                    if answers_ref_differs {
1794                        what.push("answers_ref".to_string());
1795                    }
1796                    steps.push(ApplyStep {
1797                        kind: ApplyStepKind::UpdateExtension,
1798                        key: ext_key,
1799                        action: ApplyAction::Update,
1800                        detail: what.join(", "),
1801                        idempotency_key: Some(ikey.clone()),
1802                        op: StepOp::UpdateExtension(Box::new(ExtensionBindingPayload {
1803                            environment_id: env_id_str.clone(),
1804                            kind: mx.kind.clone(),
1805                            pack_ref: mx.pack_ref.clone(),
1806                            instance_id: mx.instance_id.clone(),
1807                            answers_ref: mx.answers_ref.clone(),
1808                            idempotency_key: Some(ikey),
1809                        })),
1810                    });
1811                } else {
1812                    steps.push(ApplyStep::no_op(
1813                        ApplyStepKind::AddExtension,
1814                        ext_key,
1815                        format!("bound ({}, {})", mx.kind, mx.pack_ref),
1816                    ));
1817                }
1818            }
1819        }
1820    }
1821
1822    // 9. Endpoints (add → link → welcome-flow, per endpoint in manifest order).
1823    //    Reuses the match computed during validation (Fix 6); verify() re-matches
1824    //    the freshly reloaded env on purpose.
1825    for re in &ctx.endpoints {
1826        let ep = &re.spec;
1827        let matched = re.matched.as_ref();
1828        let endpoint_ref = match matched {
1829            Some(m) => EndpointRef::Existing(m.endpoint_id.to_string()),
1830            None => EndpointRef::CreatedByName(ep.name.clone()),
1831        };
1832
1833        match matched {
1834            None => {
1835                let desired_hash = hash_json(&json!({
1836                    "provider_type": ep.provider_type,
1837                    "secret_refs": ep.secret_refs,
1838                }));
1839                let ikey = derive_idempotency_key(
1840                    &ctx.env_id,
1841                    ApplyStepKind::AddEndpoint.label(),
1842                    &ep.name,
1843                    &desired_hash,
1844                );
1845                steps.push(ApplyStep {
1846                    kind: ApplyStepKind::AddEndpoint,
1847                    key: ep.name.clone(),
1848                    action: ApplyAction::Create,
1849                    detail: format!("{} (provider_id = name)", ep.provider_type),
1850                    idempotency_key: Some(ikey.clone()),
1851                    op: StepOp::EndpointAdd(Box::new(EndpointAddPayload {
1852                        environment_id: env_id_str.clone(),
1853                        provider_id: ep.name.clone(),
1854                        provider_type: ep.provider_type.clone(),
1855                        display_name: ep.name.clone(),
1856                        secret_refs: ep.secret_refs.clone(),
1857                        // The env-manifest doesn't carry a webhook secret ref;
1858                        // a manifest-applied telegram endpoint auto-mints via the
1859                        // local dev-store sink (env apply is local-only).
1860                        webhook_secret_ref: None,
1861                        idempotency_key: Some(ikey),
1862                        updated_by: ctx.updated_by.clone(),
1863                    })),
1864                });
1865            }
1866            Some(m) => steps.push(ApplyStep::no_op(
1867                ApplyStepKind::AddEndpoint,
1868                ep.name.clone(),
1869                format!("matched endpoint {}", m.endpoint_id),
1870            )),
1871        }
1872
1873        for link in &ep.links {
1874            let already_linked =
1875                matched.is_some_and(|m| m.linked_bundles.iter().any(|b| b.as_str() == *link));
1876            let key = format!("{} → {link}", ep.name);
1877            if already_linked {
1878                steps.push(ApplyStep::no_op(ApplyStepKind::LinkEndpoint, key, "linked"));
1879            } else {
1880                let ikey = derive_idempotency_key(
1881                    &ctx.env_id,
1882                    ApplyStepKind::LinkEndpoint.label(),
1883                    &format!("{}\u{0}{link}", ep.name),
1884                    "",
1885                );
1886                steps.push(ApplyStep {
1887                    kind: ApplyStepKind::LinkEndpoint,
1888                    key,
1889                    action: ApplyAction::Create,
1890                    detail: String::new(),
1891                    idempotency_key: Some(ikey.clone()),
1892                    op: StepOp::EndpointLink {
1893                        endpoint: endpoint_ref.clone(),
1894                        bundle_id: link.clone(),
1895                    },
1896                });
1897            }
1898        }
1899
1900        if let Some(wf) = &ep.welcome_flow {
1901            let current_equal = matched.is_some_and(|m| {
1902                m.welcome_flow.as_ref().is_some_and(|cur| {
1903                    cur.bundle_id.as_str() == wf.bundle_id
1904                        && cur.pack_id.as_str() == wf.pack_id
1905                        && cur.flow_id == wf.flow_id
1906                })
1907            });
1908            if current_equal {
1909                steps.push(ApplyStep::no_op(
1910                    ApplyStepKind::SetWelcomeFlow,
1911                    ep.name.clone(),
1912                    format!("{}/{}/{}", wf.bundle_id, wf.pack_id, wf.flow_id),
1913                ));
1914            } else {
1915                let desired_hash = hash_json(&json!({
1916                    "bundle_id": wf.bundle_id, "pack_id": wf.pack_id, "flow_id": wf.flow_id,
1917                }));
1918                let ikey = derive_idempotency_key(
1919                    &ctx.env_id,
1920                    ApplyStepKind::SetWelcomeFlow.label(),
1921                    &ep.name,
1922                    &desired_hash,
1923                );
1924                let action = if matched.is_some_and(|m| m.welcome_flow.is_some()) {
1925                    ApplyAction::Update
1926                } else {
1927                    ApplyAction::Create
1928                };
1929                steps.push(ApplyStep {
1930                    kind: ApplyStepKind::SetWelcomeFlow,
1931                    key: ep.name.clone(),
1932                    action,
1933                    detail: format!("{}/{}/{}", wf.bundle_id, wf.pack_id, wf.flow_id),
1934                    idempotency_key: Some(ikey.clone()),
1935                    op: StepOp::WelcomeFlow {
1936                        endpoint: endpoint_ref.clone(),
1937                        flow: wf.clone(),
1938                    },
1939                });
1940            }
1941        }
1942    }
1943
1944    Ok(steps)
1945}
1946
1947/// Trust-root diff: read-only — `load_existing_only` never generates a key,
1948/// so `--dry-run` leaves the disk untouched. A missing operator key plans as
1949/// `create` (bootstrap generates it at execute time).
1950fn trust_root_step(store: &LocalFsStore, ctx: &ApplyContext) -> Result<ApplyStep, OpError> {
1951    let key = ctx.env_id.as_str().to_string();
1952    let Some(_env) = &ctx.env else {
1953        return Ok(ApplyStep {
1954            kind: ApplyStepKind::BootstrapTrustRoot,
1955            key,
1956            action: ApplyAction::Create,
1957            detail: "bootstrap (fresh env)".to_string(),
1958            idempotency_key: None,
1959            op: StepOp::TrustRootBootstrap,
1960        });
1961    };
1962    let env_dir = store.env_dir(&ctx.env_id)?;
1963    let trust_root = store_trust_root::load(&env_dir)?;
1964    let operator_key = crate::operator_key::load_existing_only();
1965    let (action, detail, op) = match operator_key {
1966        Ok(k)
1967            if trust_root
1968                .keys
1969                .iter()
1970                .any(|t| t.key_id.eq_ignore_ascii_case(&k.key_id)) =>
1971        {
1972            (
1973                ApplyAction::NoOp,
1974                format!("operator key {} already trusted", short_key_id(&k.key_id)),
1975                StepOp::None,
1976            )
1977        }
1978        Ok(k) => (
1979            ApplyAction::Create,
1980            format!("trust operator key {}", short_key_id(&k.key_id)),
1981            StepOp::TrustRootBootstrap,
1982        ),
1983        Err(_) => (
1984            ApplyAction::Create,
1985            "operator key will be generated".to_string(),
1986            StepOp::TrustRootBootstrap,
1987        ),
1988    };
1989    Ok(ApplyStep {
1990        kind: ApplyStepKind::BootstrapTrustRoot,
1991        key,
1992        action,
1993        detail,
1994        idempotency_key: None,
1995        op,
1996    })
1997}
1998
1999// --- execute --------------------------------------------------------------------
2000
2001fn execute(store: &LocalFsStore, ctx: &ApplyContext, steps: &[ApplyStep]) -> Result<(), OpError> {
2002    // Post-confirmation, pre-first-mutation artifact gate: re-hash every
2003    // bundle artifact to detect changes that occurred during the
2004    // (potentially unbounded) TTY confirmation pause. A changed artifact
2005    // aborts the whole apply before ANY step (including put-secret)
2006    // mutates the store. The per-Deploy-step re-check stays because later
2007    // steps in the same run can still race with external writes.
2008    for rb in &ctx.bundles {
2009        for rr in &rb.revisions {
2010            ensure_artifact_unchanged(&rr.resolved_path, &rr.digest)?;
2011        }
2012    }
2013
2014    // Sub-verbs get clean flags: payloads are passed directly, never re-read
2015    // from the manifest path.
2016    let exec_flags = OpFlags::default();
2017    let mut created_endpoints: BTreeMap<String, String> = BTreeMap::new();
2018    let total = steps.len();
2019
2020    for (i, step) in steps.iter().enumerate() {
2021        let n = i + 1;
2022        if step.action == ApplyAction::NoOp {
2023            eprintln!(
2024                "[{n}/{total}] {:<22} {:<40} no-op",
2025                step.kind.label(),
2026                step.key
2027            );
2028            continue;
2029        }
2030        eprintln!(
2031            "[{n}/{total}] {:<22} {:<40} {}…",
2032            step.kind.label(),
2033            step.key,
2034            step.action.as_str()
2035        );
2036        let result: Result<(), OpError> = match &step.op {
2037            StepOp::None => Ok(()),
2038            StepOp::EnvInit { public_base_url } => super::env::init(
2039                store,
2040                &exec_flags,
2041                EnvInitPayload {
2042                    public_base_url: public_base_url.clone(),
2043                },
2044            )
2045            .map(|_| ()),
2046            StepOp::SetPublicUrl { url } => {
2047                super::env::set_public_url(store, &exec_flags, ctx.env_id.as_str(), url).map(|_| ())
2048            }
2049            StepOp::UpdateHostConfig(payload) => {
2050                super::config::set(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2051            }
2052            StepOp::AddPackBinding(payload) => {
2053                let payload = stage_binding_answers(store, ctx, (**payload).clone())?;
2054                super::env_packs::add(store, &exec_flags, Some(payload)).map(|_| ())
2055            }
2056            StepOp::UpdatePackBinding(payload) => {
2057                let payload = stage_binding_answers(store, ctx, (**payload).clone())?;
2058                super::env_packs::update(store, &exec_flags, Some(payload)).map(|_| ())
2059            }
2060            StepOp::AddExtension(payload) => {
2061                super::extensions::add(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2062            }
2063            StepOp::UpdateExtension(payload) => {
2064                super::extensions::update(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2065            }
2066            StepOp::TrustRootBootstrap => super::trust_root::bootstrap(
2067                store,
2068                &exec_flags,
2069                Some(TrustRootBootstrapPayload {
2070                    environment_id: ctx.env_id.as_str().to_string(),
2071                }),
2072            )
2073            .map(|_| ()),
2074            StepOp::PutSecret { path } => {
2075                let value = ctx
2076                    .secret_values
2077                    .get(path)
2078                    .expect("validated: every put-secret step has a resolved value");
2079                super::secrets::put(
2080                    store,
2081                    &exec_flags,
2082                    Some(SecretsPutPayload {
2083                        environment_id: ctx.env_id.as_str().to_string(),
2084                        path: path.clone(),
2085                        value: value.expose().to_string(),
2086                        idempotency_key: step.idempotency_key.clone(),
2087                    }),
2088                )
2089                .map(|_| ())
2090            }
2091            StepOp::Deploy {
2092                payload,
2093                expected_digest,
2094            } => {
2095                ensure_artifact_unchanged(
2096                    payload
2097                        .bundle_path
2098                        .as_deref()
2099                        .expect("apply-built Deploy always has bundle_path"),
2100                    expected_digest,
2101                )?;
2102                super::deploy::deploy(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2103            }
2104            StepOp::DeploySplit { .. } => {
2105                execute_deploy_split(store, &exec_flags, &step.op)?;
2106                Ok(())
2107            }
2108            StepOp::BundleUpdate(payload) => {
2109                super::bundles::update(store, &exec_flags, Some((**payload).clone())).map(|_| ())
2110            }
2111            StepOp::EndpointAdd(payload) => super::messaging::add(
2112                store,
2113                &exec_flags,
2114                Some((**payload).clone()),
2115            )
2116            .map(|outcome| {
2117                if let Ok(summary) = serde_json::from_value::<EndpointSummary>(outcome.result) {
2118                    created_endpoints.insert(payload.display_name.clone(), summary.endpoint_id);
2119                }
2120            }),
2121            StepOp::EndpointLink {
2122                endpoint,
2123                bundle_id,
2124            } => resolve_endpoint_id(endpoint, &created_endpoints).and_then(|endpoint_id| {
2125                super::messaging::link_bundle(
2126                    store,
2127                    &exec_flags,
2128                    Some(EndpointLinkBundlePayload {
2129                        environment_id: ctx.env_id.as_str().to_string(),
2130                        endpoint_id,
2131                        bundle_id: bundle_id.clone(),
2132                        idempotency_key: step.idempotency_key.clone(),
2133                        updated_by: ctx.updated_by.clone(),
2134                    }),
2135                )
2136                .map(|_| ())
2137            }),
2138            StepOp::WelcomeFlow { endpoint, flow } => {
2139                resolve_endpoint_id(endpoint, &created_endpoints).and_then(|endpoint_id| {
2140                    super::messaging::set_welcome_flow(
2141                        store,
2142                        &exec_flags,
2143                        Some(EndpointSetWelcomeFlowPayload {
2144                            environment_id: ctx.env_id.as_str().to_string(),
2145                            endpoint_id,
2146                            bundle_id: flow.bundle_id.clone(),
2147                            pack_id: flow.pack_id.clone(),
2148                            flow_id: flow.flow_id.clone(),
2149                            idempotency_key: step.idempotency_key.clone(),
2150                            updated_by: ctx.updated_by.clone(),
2151                        }),
2152                    )
2153                    .map(|_| ())
2154                })
2155            }
2156        };
2157        if let Err(err) = result {
2158            let remaining = total - n;
2159            eprintln!(
2160                "apply: step {n}/{total} `{} {}` failed; {remaining} step(s) not attempted. \
2161                 Fix the cause and re-run the same apply — completed steps replay as no-ops.",
2162                step.kind.label(),
2163                step.key
2164            );
2165            return Err(err);
2166        }
2167    }
2168    Ok(())
2169}
2170
2171/// Resolve an [`EndpointRef`] to a concrete `endpoint_id`. `CreatedByName`
2172/// entries are filled in-process from the `add-endpoint` outcome of this
2173/// same run — the exact output-capture plumbing the manifest eliminates for
2174/// the operator.
2175fn resolve_endpoint_id(
2176    endpoint: &EndpointRef,
2177    created: &BTreeMap<String, String>,
2178) -> Result<String, OpError> {
2179    match endpoint {
2180        EndpointRef::Existing(id) => Ok(id.clone()),
2181        EndpointRef::CreatedByName(name) => created.get(name).cloned().ok_or_else(|| {
2182            OpError::InvalidArgument(format!(
2183                "internal: endpoint `{name}` was not created before its link step \
2184                 (add-endpoint outcome missing)"
2185            ))
2186        }),
2187    }
2188}
2189
2190// --- verify ---------------------------------------------------------------------
2191
2192/// Store-level post-conditions: re-read the environment and assert the
2193/// manifest's desired state is visible. Runtime readiness is the doctor's
2194/// job (PR-3), not apply's. Secrets are deliberately NOT verified: there is
2195/// no `get` until A9, and reader-side resolvability is exactly the doctor's
2196/// check — `put`'s own write-through already failed the step if the store
2197/// rejected it.
2198fn verify(store: &LocalFsStore, ctx: &ApplyContext) -> Result<Value, OpError> {
2199    let env = store.load(&ctx.env_id)?;
2200    let mut failures: Vec<String> = Vec::new();
2201    let mut checked = 0usize;
2202
2203    if let Some(url) = &ctx.canonical_public_base_url {
2204        checked += 1;
2205        if env.host_config.public_base_url.as_deref() != Some(url.as_str()) {
2206            failures.push(format!(
2207                "public_base_url is `{:?}`, expected `{url}`",
2208                env.host_config.public_base_url
2209            ));
2210        }
2211    }
2212
2213    // Host-config fields.
2214    {
2215        let me = &ctx.manifest.environment;
2216        if let Some(name) = &me.name {
2217            checked += 1;
2218            if *name != env.name {
2219                failures.push(format!("name is `{}`, expected `{name}`", env.name));
2220            }
2221        }
2222        if let Some(region) = &me.region {
2223            checked += 1;
2224            if env.host_config.region.as_deref() != Some(region.as_str()) {
2225                failures.push(format!(
2226                    "region is `{:?}`, expected `{region}`",
2227                    env.host_config.region
2228                ));
2229            }
2230        }
2231        if let Some(tenant_org_id) = &me.tenant_org_id {
2232            checked += 1;
2233            if env.host_config.tenant_org_id.as_deref() != Some(tenant_org_id.as_str()) {
2234                failures.push(format!(
2235                    "tenant_org_id is `{:?}`, expected `{tenant_org_id}`",
2236                    env.host_config.tenant_org_id
2237                ));
2238            }
2239        }
2240        if let Some(listen_addr) = &me.listen_addr {
2241            checked += 1;
2242            let parsed: std::net::SocketAddr = listen_addr
2243                .parse()
2244                .expect("validate_shape already validated listen_addr");
2245            if env.host_config.listen_addr != Some(parsed) {
2246                failures.push(format!(
2247                    "listen_addr is `{:?}`, expected `{parsed}`",
2248                    env.host_config.listen_addr
2249                ));
2250            }
2251        }
2252        if let Some(gui_enabled) = me.gui_enabled {
2253            checked += 1;
2254            if env.host_config.gui_enabled != Some(gui_enabled) {
2255                failures.push(format!(
2256                    "gui_enabled is `{:?}`, expected `{gui_enabled}`",
2257                    env.host_config.gui_enabled
2258                ));
2259            }
2260        }
2261    }
2262
2263    if ctx.manifest.trust_root == Some(TrustRootDirective::Bootstrap) {
2264        checked += 1;
2265        let env_dir = store.env_dir(&ctx.env_id)?;
2266        let trust_root = store_trust_root::load(&env_dir)?;
2267        match crate::operator_key::load_existing_only() {
2268            Ok(k)
2269                if trust_root
2270                    .keys
2271                    .iter()
2272                    .any(|t| t.key_id.eq_ignore_ascii_case(&k.key_id)) => {}
2273            Ok(k) => failures.push(format!(
2274                "operator key {} is not in the trust root",
2275                short_key_id(&k.key_id)
2276            )),
2277            Err(e) => failures.push(format!("operator key not loadable: {e}")),
2278        }
2279    }
2280
2281    // Env-packs.
2282    for mp in &ctx.manifest.packs {
2283        checked += 1;
2284        let Some(b) = env.pack_for_slot(mp.slot) else {
2285            failures.push(format!("pack slot `{}` is not bound", mp.slot));
2286            continue;
2287        };
2288        if b.kind.to_string() != mp.kind {
2289            failures.push(format!(
2290                "pack slot `{}`: kind is `{}`, expected `{}`",
2291                mp.slot, b.kind, mp.kind
2292            ));
2293        }
2294        if b.pack_ref.as_str() != mp.pack_ref {
2295            failures.push(format!(
2296                "pack slot `{}`: pack_ref is `{}`, expected `{}`",
2297                mp.slot,
2298                b.pack_ref.as_str(),
2299                mp.pack_ref
2300            ));
2301        }
2302        if mp.answers_ref.is_some()
2303            && b.answers_ref.as_deref() != Some(staged_answers_rel(mp.slot).as_path())
2304        {
2305            failures.push(format!(
2306                "pack slot `{}`: answers_ref is `{:?}`, expected `{:?}`",
2307                mp.slot,
2308                b.answers_ref,
2309                staged_answers_rel(mp.slot)
2310            ));
2311        }
2312    }
2313
2314    // Extensions.
2315    for mx in &ctx.manifest.extensions {
2316        checked += 1;
2317        let kind_path = greentic_deploy_spec::PackDescriptor::try_new(&mx.kind)
2318            .expect("validate_shape already validated kind")
2319            .path()
2320            .to_string();
2321        let ext_key = format!(
2322            "{}{}",
2323            kind_path,
2324            mx.instance_id
2325                .as_ref()
2326                .map(|i| format!("/{i}"))
2327                .unwrap_or_default()
2328        );
2329        let Some(b) = env
2330            .extensions
2331            .iter()
2332            .find(|b| b.kind.path() == kind_path && b.instance_id == mx.instance_id)
2333        else {
2334            failures.push(format!("extension `{ext_key}` is not bound"));
2335            continue;
2336        };
2337        if b.kind.to_string() != mx.kind {
2338            failures.push(format!(
2339                "extension `{ext_key}`: kind is `{}`, expected `{}`",
2340                b.kind, mx.kind
2341            ));
2342        }
2343        if b.pack_ref.as_str() != mx.pack_ref {
2344            failures.push(format!(
2345                "extension `{ext_key}`: pack_ref is `{}`, expected `{}`",
2346                b.pack_ref.as_str(),
2347                mx.pack_ref
2348            ));
2349        }
2350    }
2351
2352    for rb in &ctx.bundles {
2353        checked += 1;
2354        let Some(dep) = env
2355            .bundles
2356            .iter()
2357            .find(|d| d.bundle_id.as_str() == rb.spec.bundle_id && d.customer_id == rb.customer_id)
2358        else {
2359            failures.push(format!("bundle `{}` is not deployed", rb.spec.bundle_id));
2360            continue;
2361        };
2362        let is_multi = rb.spec.revisions.is_some();
2363        if is_multi {
2364            if !split_converged(&env, dep.deployment_id, &rb.revisions) {
2365                failures.push(format!(
2366                    "bundle `{}`: traffic split not converged ({} revision(s) expected)",
2367                    rb.spec.bundle_id,
2368                    rb.revisions.len()
2369                ));
2370            }
2371        } else {
2372            let primary_digest = &rb.revisions[0].digest;
2373            if !deployment_converged(
2374                &env,
2375                dep.deployment_id,
2376                primary_digest,
2377                rb.spec.bundle_source_uri.as_deref(),
2378            ) {
2379                failures.push(format!(
2380                    "bundle `{}`: live revision digest is `{}`, expected `{}`",
2381                    rb.spec.bundle_id,
2382                    live_revision_digest(&env, dep.deployment_id).unwrap_or("none"),
2383                    primary_digest
2384                ));
2385            }
2386        }
2387        if let Some(binding) = &rb.spec.route_binding {
2388            let desired = into_route_binding(binding.clone());
2389            if desired != dep.route_binding {
2390                failures.push(format!(
2391                    "bundle `{}`: route_binding differs from the manifest",
2392                    rb.spec.bundle_id
2393                ));
2394            }
2395        }
2396        if let Some(overrides) = &rb.spec.config_overrides
2397            && *overrides != dep.config_overrides
2398        {
2399            failures.push(format!(
2400                "bundle `{}`: config_overrides differ from the manifest",
2401                rb.spec.bundle_id
2402            ));
2403        }
2404        // revenue_share (G2) is threaded into the create path AND reconciled
2405        // for existing deployments, so it is correct after a single apply —
2406        // assert it whenever declared.
2407        if let Some(shares) = &rb.spec.revenue_share
2408            && convert_revenue_share(shares) != dep.revenue_share
2409        {
2410            failures.push(format!(
2411                "bundle `{}`: revenue_share differs from the manifest",
2412                rb.spec.bundle_id
2413            ));
2414        }
2415        // status (G3) is reconcile-only against an EXISTING deployment. A
2416        // deployment created during THIS apply is born `active`, so a declared
2417        // non-`active` status converges on the next apply — don't fail verify
2418        // for it here. Assert only when the deployment pre-dated this apply.
2419        let existed_pre_apply = ctx.env.as_ref().is_some_and(|e| {
2420            e.bundles
2421                .iter()
2422                .any(|d| d.bundle_id == dep.bundle_id && d.customer_id == dep.customer_id)
2423        });
2424        if let Some(status) = rb.spec.status
2425            && existed_pre_apply
2426            && status != dep.status
2427        {
2428            failures.push(format!(
2429                "bundle `{}`: status is `{:?}`, expected `{status:?}`",
2430                rb.spec.bundle_id, dep.status
2431            ));
2432        }
2433    }
2434
2435    // Verify endpoints against the freshly reloaded env (re-match on
2436    // purpose — this IS the verify, not a cached result from validation).
2437    for ep in &ctx.manifest.messaging_endpoints {
2438        checked += 1;
2439        let Some(m) = match_existing_endpoint(&env, ep)? else {
2440            failures.push(format!("endpoint `{}` is absent", ep.name));
2441            continue;
2442        };
2443        for link in &ep.links {
2444            if !m.linked_bundles.iter().any(|b| b.as_str() == *link) {
2445                failures.push(format!("endpoint `{}`: link `{link}` is absent", ep.name));
2446            }
2447        }
2448        if let Some(wf) = &ep.welcome_flow {
2449            let equal = m.welcome_flow.as_ref().is_some_and(|cur| {
2450                cur.bundle_id.as_str() == wf.bundle_id
2451                    && cur.pack_id.as_str() == wf.pack_id
2452                    && cur.flow_id == wf.flow_id
2453            });
2454            if !equal {
2455                failures.push(format!(
2456                    "endpoint `{}`: welcome_flow differs from the manifest",
2457                    ep.name
2458                ));
2459            }
2460        }
2461    }
2462
2463    if failures.is_empty() {
2464        Ok(json!({ "checked": checked, "failures": [] }))
2465    } else {
2466        Err(OpError::Conflict(format!(
2467            "apply executed but post-verify found {} mismatch(es): {}",
2468            failures.len(),
2469            failures.join("; ")
2470        )))
2471    }
2472}
2473
2474// --- helpers --------------------------------------------------------------------
2475
2476/// True when the manifest declares a revenue-share that differs from the live
2477/// deployment's split. Absent in the manifest = "leave untouched" = no diff.
2478fn revenue_share_differs(
2479    rb: &ResolvedBundle,
2480    dep: &greentic_deploy_spec::BundleDeployment,
2481) -> bool {
2482    rb.spec
2483        .revenue_share
2484        .as_ref()
2485        .is_some_and(|s| convert_revenue_share(s) != dep.revenue_share)
2486}
2487
2488/// True when the manifest declares a status that differs from the live
2489/// deployment's status. Absent in the manifest = "leave untouched" = no diff.
2490fn status_differs(rb: &ResolvedBundle, dep: &greentic_deploy_spec::BundleDeployment) -> bool {
2491    rb.spec.status.is_some_and(|s| s != dep.status)
2492}
2493
2494/// Desired `bundles update` mutations for one bundle entry, derived against a
2495/// live deployment. Each `Some` field is applied; an all-`None` set means no
2496/// update step is needed.
2497struct BundleMetaDiff {
2498    /// Set when the manifest route binding differs from the live binding.
2499    route_binding: Option<RouteBindingPayload>,
2500    /// Set when overrides differ AND the deploy/stage gate allows applying
2501    /// them this run (they otherwise ride the deploy, or defer one apply).
2502    config_overrides: Option<BTreeMap<String, BTreeMap<String, Value>>>,
2503    /// Set when the manifest revenue-share differs from the live split.
2504    revenue_share: Option<Vec<RevenueShareEntryPayload>>,
2505    /// Set when the manifest status differs from the live status.
2506    status: Option<BundleDeploymentStatus>,
2507}
2508
2509impl BundleMetaDiff {
2510    fn is_noop(&self) -> bool {
2511        self.route_binding.is_none()
2512            && self.config_overrides.is_none()
2513            && self.revenue_share.is_none()
2514            && self.status.is_none()
2515    }
2516}
2517
2518/// Build a `bundles update` step from the desired metadata mutations, or
2519/// `None` when nothing differs. Centralizes the idempotency-key derivation,
2520/// the human `detail` string, and the payload so both reconcile arms
2521/// (single- and multi-revision) share one construction.
2522fn bundle_meta_update_step(
2523    env_id: &EnvId,
2524    env_id_str: &str,
2525    bundle_id: &str,
2526    deployment_id: DeploymentId,
2527    desired_binding: &Option<RouteBinding>,
2528    diff: BundleMetaDiff,
2529) -> Option<ApplyStep> {
2530    if diff.is_noop() {
2531        return None;
2532    }
2533    // Hash the revenue-share as ordered (party, bps) pairs (the payload type
2534    // isn't directly hashable through `hash_json` without this projection).
2535    let revenue_share_hash = diff.revenue_share.as_ref().map(|s| {
2536        s.iter()
2537            .map(|e| (e.party_id.clone(), e.basis_points))
2538            .collect::<Vec<_>>()
2539    });
2540    let desired_hash = hash_json(&json!({
2541        "route_binding": diff.route_binding,
2542        "config_overrides": diff.config_overrides,
2543        "revenue_share": revenue_share_hash,
2544        "status": diff.status,
2545    }));
2546    let ikey = derive_idempotency_key(
2547        env_id,
2548        ApplyStepKind::UpdateBundle.label(),
2549        bundle_id,
2550        &desired_hash,
2551    );
2552    let mut what = Vec::new();
2553    if diff.route_binding.is_some() {
2554        what.push(format!("binding → {}", binding_summary(desired_binding)));
2555    }
2556    if diff.config_overrides.is_some() {
2557        what.push("config_overrides".to_string());
2558    }
2559    if diff.revenue_share.is_some() {
2560        what.push("revenue_share".to_string());
2561    }
2562    if let Some(s) = diff.status {
2563        what.push(format!("status → {s:?}"));
2564    }
2565    Some(ApplyStep {
2566        kind: ApplyStepKind::UpdateBundle,
2567        key: bundle_id.to_string(),
2568        action: ApplyAction::Update,
2569        detail: what.join(", "),
2570        idempotency_key: Some(ikey.clone()),
2571        op: StepOp::BundleUpdate(Box::new(BundleUpdatePayload {
2572            environment_id: env_id_str.to_string(),
2573            deployment_id: deployment_id.to_string(),
2574            status: diff.status,
2575            route_binding: diff.route_binding,
2576            revenue_share: diff.revenue_share,
2577            config_overrides: diff.config_overrides,
2578            idempotency_key: Some(ikey),
2579        })),
2580    })
2581}
2582
2583/// Canonical in-store location for a pack binding's staged answers file —
2584/// `env-packs/<slot>/answers.json`, relative to the env dir. Matches the
2585/// path convention the reconcile resolver ([`super::env::load_render_answers`])
2586/// expects.
2587fn staged_answers_rel(slot: CapabilitySlot) -> PathBuf {
2588    PathBuf::from("env-packs")
2589        .join(slot.to_string())
2590        .join("answers.json")
2591}
2592
2593/// Resolve a binding's `answers_ref` against the manifest dir (the schema
2594/// contract: paths are manifest-relative).
2595fn resolve_answers_src(manifest_dir: &Path, manifest_ref: &Path) -> PathBuf {
2596    if manifest_ref.is_absolute() {
2597        manifest_ref.to_path_buf()
2598    } else {
2599        manifest_dir.join(manifest_ref)
2600    }
2601}
2602
2603/// True when the staged answers file is missing or its content differs from
2604/// the manifest source — i.e. apply must (re)stage it. Identical content
2605/// re-applies as a no-op, so apply stays idempotent.
2606fn staged_answers_outdated(
2607    store: &LocalFsStore,
2608    env_id: &EnvId,
2609    manifest_dir: &Path,
2610    slot: CapabilitySlot,
2611    manifest_ref: &Path,
2612) -> Result<bool, OpError> {
2613    let src = resolve_answers_src(manifest_dir, manifest_ref);
2614    let staged = store.env_dir(env_id)?.join(staged_answers_rel(slot));
2615    let src_bytes = std::fs::read(&src).map_err(|source| OpError::Io {
2616        path: src.clone(),
2617        source,
2618    })?;
2619    match std::fs::read(&staged) {
2620        Ok(staged_bytes) => Ok(staged_bytes != src_bytes),
2621        Err(_) => Ok(true),
2622    }
2623}
2624
2625/// Copy a binding's manifest-relative `answers_ref` into the env store at the
2626/// canonical path and return that env-relative path. Apply records the
2627/// returned ref on the binding so reconcile — which resolves `answers_ref`
2628/// against the env dir — finds the file. Skips a no-op self-copy when source
2629/// and destination are already the same file.
2630fn stage_answers_file(
2631    store: &LocalFsStore,
2632    env_id: &EnvId,
2633    manifest_dir: &Path,
2634    slot: CapabilitySlot,
2635    manifest_ref: &Path,
2636) -> Result<PathBuf, OpError> {
2637    let src = resolve_answers_src(manifest_dir, manifest_ref);
2638    let rel = staged_answers_rel(slot);
2639    let dest = store.env_dir(env_id)?.join(&rel);
2640    let same_file = dest.exists() && src.canonicalize().ok() == dest.canonicalize().ok();
2641    if !same_file {
2642        if let Some(parent) = dest.parent() {
2643            std::fs::create_dir_all(parent).map_err(|source| OpError::Io {
2644                path: parent.to_path_buf(),
2645                source,
2646            })?;
2647        }
2648        std::fs::copy(&src, &dest).map_err(|source| OpError::Io {
2649            path: src.clone(),
2650            source,
2651        })?;
2652    }
2653    Ok(rel)
2654}
2655
2656/// Stage a pack binding's answers file (if any) and rewrite the payload's
2657/// `answers_ref` to the env-relative staged path before it is persisted.
2658/// Bindings without an answers_ref pass through unchanged.
2659fn stage_binding_answers(
2660    store: &LocalFsStore,
2661    ctx: &ApplyContext,
2662    mut payload: EnvPackBindingPayload,
2663) -> Result<EnvPackBindingPayload, OpError> {
2664    if let Some(manifest_ref) = payload.answers_ref.clone() {
2665        payload.answers_ref = Some(stage_answers_file(
2666            store,
2667            &ctx.env_id,
2668            &ctx.manifest_dir,
2669            payload.slot,
2670            &manifest_ref,
2671        )?);
2672    }
2673    Ok(payload)
2674}
2675
2676/// Hash a resolved bundle artifact and, when the manifest pins a `bundle_digest`,
2677/// fail closed unless the file matches. Returns the computed `sha256:` digest
2678/// recorded on the revision. `location` labels the bundle (and revision, for a
2679/// split) in the mismatch error. Applies uniformly to local `bundle_path`
2680/// artifacts and to ones fetched from a `bundle_source_uri`.
2681fn resolved_artifact_digest(
2682    path: &std::path::Path,
2683    declared: Option<&str>,
2684    location: &str,
2685) -> Result<String, OpError> {
2686    let digest = super::bundle_stage::sha256_file(path).map_err(|source| OpError::Io {
2687        path: path.to_path_buf(),
2688        source,
2689    })?;
2690    if let Some(declared) = declared
2691        && declared != digest
2692    {
2693        return Err(OpError::Conflict(format!(
2694            "{location}: declared bundle_digest `{declared}` does not match the resolved \
2695             artifact digest `{digest}`"
2696        )));
2697    }
2698    Ok(digest)
2699}
2700
2701/// Build a [`BundleDeployPayload`] from a resolved single-revision bundle.
2702/// The primary (first) resolved revision supplies the artifact path.
2703fn deploy_payload(
2704    env_id: &str,
2705    rb: &ResolvedBundle,
2706    route_binding: Option<RouteBindingPayload>,
2707) -> BundleDeployPayload {
2708    BundleDeployPayload {
2709        environment_id: env_id.to_string(),
2710        bundle_id: rb.spec.bundle_id.clone(),
2711        customer_id: rb.spec.customer_id.clone(),
2712        bundle_path: Some(rb.revisions[0].resolved_path.clone()),
2713        // Single-revision pull ref: a K8s worker fetches the bundle from here
2714        // at boot; `bundle_path` above supplies the integrity digest.
2715        bundle_source_uri: rb.spec.bundle_source_uri.clone(),
2716        // Local manifest-apply path: the artifact at `bundle_path` is staged
2717        // locally, so no remote pins are threaded.
2718        remote_pins: None,
2719        idempotency_key: None,
2720        config_overrides: rb.spec.config_overrides.clone(),
2721        route_binding,
2722        // Threaded into the FRESH-add path; ignored by `deploy` on a
2723        // re-deploy (the existing split is reconciled via `bundles update`).
2724        revenue_share: rb.spec.revenue_share.clone(),
2725    }
2726}
2727
2728/// Build a [`StepOp::DeploySplit`] from a resolved multi-revision bundle.
2729fn deploy_split_op(env_id: &str, rb: &ResolvedBundle) -> StepOp {
2730    StepOp::DeploySplit {
2731        env_id: env_id.to_string(),
2732        bundle_id: rb.spec.bundle_id.clone(),
2733        customer_id: rb.spec.customer_id.clone(),
2734        config_overrides: rb.spec.config_overrides.clone(),
2735        route_binding: rb.spec.route_binding.clone(),
2736        revenue_share: rb.spec.revenue_share.clone(),
2737        revisions: rb
2738            .revisions
2739            .iter()
2740            .map(|rr| SplitRevisionEntry {
2741                name: rr.spec.name.clone(),
2742                resolved_path: rr.resolved_path.clone(),
2743                expected_digest: rr.digest.clone(),
2744                weight_bps: rr.weight_bps,
2745                drain_seconds: rr.spec.drain_seconds,
2746                bundle_source_uri: rr.spec.bundle_source_uri.clone(),
2747            })
2748            .collect(),
2749    }
2750}
2751
2752/// Execute a multi-revision deploy: add the bundle (if new), stage each
2753/// revision, warm each, then set the combined traffic split. Takes the
2754/// whole `StepOp` by reference and destructures the `DeploySplit` variant.
2755fn execute_deploy_split(store: &LocalFsStore, flags: &OpFlags, op: &StepOp) -> Result<(), OpError> {
2756    use super::bundles::{BundleAddPayload, BundleSummary};
2757    use super::revisions::{RevisionStagePayload, RevisionSummary, RevisionTransitionPayload};
2758    use super::traffic::{TrafficSetEntryPayload, TrafficSetPayload};
2759
2760    let StepOp::DeploySplit {
2761        env_id,
2762        bundle_id,
2763        customer_id,
2764        config_overrides,
2765        route_binding,
2766        revenue_share,
2767        revisions: split_revs,
2768    } = op
2769    else {
2770        unreachable!("execute_deploy_split called with non-DeploySplit op");
2771    };
2772
2773    let env_id_parsed = EnvId::try_from(env_id.as_str())
2774        .map_err(|e| OpError::InvalidArgument(format!("environment_id: {e}")))?;
2775    let resolved_customer =
2776        super::bundles::resolve_customer_id(&env_id_parsed, customer_id.clone())?;
2777
2778    let env = store.load(&env_id_parsed)?;
2779    let existing = env
2780        .bundles
2781        .iter()
2782        .find(|b| b.bundle_id.as_str() == bundle_id.as_str() && b.customer_id == resolved_customer);
2783
2784    let deployment_id = match existing {
2785        Some(b) => b.deployment_id.to_string(),
2786        None => {
2787            let add_payload = BundleAddPayload {
2788                environment_id: env_id.clone(),
2789                bundle_id: bundle_id.clone(),
2790                customer_id: customer_id.clone(),
2791                route_binding: route_binding.clone().unwrap_or_default(),
2792                revenue_share: revenue_share
2793                    .clone()
2794                    .unwrap_or_else(super::bundles::default_revenue_share),
2795                authorization_ref: super::bundles::default_authorization_ref(),
2796                config_overrides: config_overrides.clone().unwrap_or_default(),
2797                idempotency_key: None,
2798            };
2799            let outcome = super::bundles::add(store, flags, Some(add_payload))?;
2800            let summary: BundleSummary = serde_json::from_value(outcome.result).map_err(|e| {
2801                OpError::InvalidArgument(format!("internal: bundle add summary: {e}"))
2802            })?;
2803            summary.deployment_id
2804        }
2805    };
2806
2807    // Stage + warm each revision.
2808    let mut traffic_entries = Vec::with_capacity(split_revs.len());
2809    for rev in split_revs {
2810        eprintln!(
2811            "  split: staging revision `{}` ({})",
2812            rev.name,
2813            short_digest(&rev.expected_digest)
2814        );
2815        // TOCTOU re-check per revision.
2816        ensure_artifact_unchanged(&rev.resolved_path, &rev.expected_digest)?;
2817
2818        let stage_payload = RevisionStagePayload {
2819            environment_id: env_id.to_string(),
2820            deployment_id: deployment_id.clone(),
2821            // Local `--bundle` stage: the store mints the id + key.
2822            revision_id: None,
2823            idempotency_key: None,
2824            bundle_path: Some(rev.resolved_path.clone()),
2825            bundle_digest: super::revisions::default_bundle_digest(),
2826            // Pull ref the manifest declared for this revision (`oci://` /
2827            // `repo://` / `store://`); a K8s worker fetches the bundle from
2828            // here at boot. `bundle_path` above supplies the integrity digest.
2829            // `None` = local-serve only.
2830            bundle_source_uri: rev.bundle_source_uri.clone(),
2831            pack_list: Vec::new(),
2832            pack_list_lock_ref: PathBuf::new(),
2833            config_digest: super::revisions::default_config_digest(),
2834            signature_sidecar_ref: super::revisions::default_signature_sidecar_ref(),
2835            drain_seconds: rev
2836                .drain_seconds
2837                .unwrap_or_else(super::revisions::default_drain_seconds),
2838        };
2839        let stage_outcome = super::revisions::stage(store, flags, Some(stage_payload))?;
2840        let staged: RevisionSummary =
2841            serde_json::from_value(stage_outcome.result).map_err(|e| {
2842                OpError::InvalidArgument(format!("internal: revision stage summary: {e}"))
2843            })?;
2844
2845        super::revisions::warm(
2846            store,
2847            flags,
2848            Some(RevisionTransitionPayload {
2849                environment_id: env_id.to_string(),
2850                revision_id: staged.revision_id.clone(),
2851                idempotency_key: None,
2852            }),
2853        )?;
2854
2855        traffic_entries.push(TrafficSetEntryPayload {
2856            revision_id: staged.revision_id,
2857            weight_bps: Some(rev.weight_bps),
2858            weight_percent: None,
2859        });
2860    }
2861
2862    // Set the combined traffic split.
2863    let ikey = format!(
2864        "deploy-split:{deployment_id}:{}",
2865        traffic_entries
2866            .iter()
2867            .map(|e| e.revision_id.as_str())
2868            .collect::<Vec<_>>()
2869            .join("+")
2870    );
2871    super::traffic::set(
2872        store,
2873        flags,
2874        Some(TrafficSetPayload {
2875            environment_id: env_id.to_string(),
2876            deployment_id: deployment_id.clone(),
2877            entries: traffic_entries,
2878            updated_by: super::traffic::default_updated_by(),
2879            idempotency_key: ikey,
2880            authorization_ref: super::traffic::default_authorization_ref(),
2881        }),
2882    )?;
2883
2884    Ok(())
2885}
2886
2887/// Deterministic, replay-safe idempotency key:
2888/// `ulid(truncate_128(sha256(schema ‖ env ‖ kind ‖ natural_key ‖ desired_hash)))`.
2889/// Same manifest → same keys (a retry replays); changed desired state → new
2890/// keys (a real mutation). Rendered as a 26-char Crockford-base32 ULID so it
2891/// satisfies the `greentic-deploy-spec` `IdempotencyKey` shape.
2892fn derive_idempotency_key(
2893    env_id: &EnvId,
2894    step_kind: &str,
2895    natural_key: &str,
2896    desired_state_hash: &str,
2897) -> String {
2898    let mut hasher = Sha256::new();
2899    for part in [
2900        ENV_MANIFEST_SCHEMA_V1,
2901        env_id.as_str(),
2902        step_kind,
2903        natural_key,
2904        desired_state_hash,
2905    ] {
2906        hasher.update(part.as_bytes());
2907        hasher.update([0u8]);
2908    }
2909    let digest = hasher.finalize();
2910    let mut bytes = [0u8; 16];
2911    bytes.copy_from_slice(&digest[..16]);
2912    ulid::Ulid::from(u128::from_be_bytes(bytes)).to_string()
2913}
2914
2915/// SHA-256 of the canonical JSON serialization (keys sorted by serde_json).
2916/// The output feeds deterministic idempotency keys, so the serialization
2917/// must stay canonical across builds (no `preserve_order` feature).
2918fn hash_json(value: &Value) -> String {
2919    let mut hasher = Sha256::new();
2920    hasher.update(value.to_string().as_bytes());
2921    hex::encode(hasher.finalize())
2922}
2923
2924/// Digest of the revision currently carrying the (highest-weight) live
2925/// traffic for a deployment. `None` when the deployment has no split or the
2926/// split references an unknown revision.
2927fn live_revision_digest(env: &Environment, deployment_id: DeploymentId) -> Option<&str> {
2928    let split = env
2929        .traffic_splits
2930        .iter()
2931        .find(|s| s.deployment_id == deployment_id)?;
2932    let entry = split.entries.iter().max_by_key(|e| e.weight_bps)?;
2933    env.revisions
2934        .iter()
2935        .find(|r| r.revision_id == entry.revision_id)
2936        .map(|r| r.bundle_digest.as_str())
2937}
2938
2939/// A digest the diff can trust: real `sha256:` material, not the
2940/// `sha256:00` placeholder pre-digest revisions carry.
2941pub(super) fn digest_is_real(digest: &str) -> bool {
2942    digest.starts_with("sha256:") && digest.len() > "sha256:".len() && digest != "sha256:00"
2943}
2944
2945/// Strict convergence: the deployment's traffic split has EXACTLY ONE entry
2946/// at full weight (10,000 bps), that entry's revision exists, carries a real
2947/// digest, the digest matches `expected_digest`, and `bundle_source_uri`
2948/// matches (`None` vs `Some` counts as a difference — a K8s worker needs
2949/// the pull ref to boot). A mixed split (e.g. 60/40 blue-green) or a
2950/// degenerate placeholder digest is NOT converged.
2951fn deployment_converged(
2952    env: &Environment,
2953    deployment_id: DeploymentId,
2954    expected_digest: &str,
2955    expected_source_uri: Option<&str>,
2956) -> bool {
2957    let Some(split) = env
2958        .traffic_splits
2959        .iter()
2960        .find(|s| s.deployment_id == deployment_id)
2961    else {
2962        return false;
2963    };
2964    if split.entries.len() != 1 || split.entries[0].weight_bps != super::deploy::FULL_TRAFFIC_BPS {
2965        return false;
2966    }
2967    let entry = &split.entries[0];
2968    env.revisions
2969        .iter()
2970        .find(|r| r.revision_id == entry.revision_id)
2971        .is_some_and(|r| {
2972            digest_is_real(&r.bundle_digest)
2973                && r.bundle_digest == expected_digest
2974                && r.bundle_source_uri.as_deref() == expected_source_uri
2975        })
2976}
2977
2978/// Multi-revision convergence: the deployment's traffic split is the exact
2979/// `(digest, weight_bps, bundle_source_uri)` multiset declared by `expected`
2980/// — every live entry resolves to a real-digest revision, and the live
2981/// `(digest, weight, source_uri)` bag equals the expected bag.
2982/// Order-independent (both bags are sorted before comparison) and
2983/// duplicate-safe: two expected revisions that share the same artifact AND
2984/// weight require two matching live entries, not one. A false "converged" is
2985/// the dangerous direction — it would silently skip applying the desired
2986/// split — so this is multiset equality, not set containment.
2987fn split_converged(
2988    env: &Environment,
2989    deployment_id: DeploymentId,
2990    expected: &[ResolvedRevision],
2991) -> bool {
2992    let Some(split) = env
2993        .traffic_splits
2994        .iter()
2995        .find(|s| s.deployment_id == deployment_id)
2996    else {
2997        return false;
2998    };
2999    if split.entries.len() != expected.len() {
3000        return false;
3001    }
3002    // Live `(digest, weight_bps, source_uri)` bag. Any entry pointing at a
3003    // missing or placeholder-digest revision fails convergence outright.
3004    let mut live_bag: Vec<(&str, u32, Option<&str>)> = Vec::with_capacity(split.entries.len());
3005    for entry in &split.entries {
3006        let Some(rev) = env
3007            .revisions
3008            .iter()
3009            .find(|r| r.revision_id == entry.revision_id)
3010        else {
3011            return false;
3012        };
3013        if !digest_is_real(&rev.bundle_digest) {
3014            return false;
3015        }
3016        live_bag.push((
3017            rev.bundle_digest.as_str(),
3018            entry.weight_bps,
3019            rev.bundle_source_uri.as_deref(),
3020        ));
3021    }
3022    // Expected `(digest, weight_bps, source_uri)` bag.
3023    let mut expected_bag: Vec<(&str, u32, Option<&str>)> = expected
3024        .iter()
3025        .map(|rr| {
3026            (
3027                rr.digest.as_str(),
3028                rr.weight_bps,
3029                rr.spec.bundle_source_uri.as_deref(),
3030            )
3031        })
3032        .collect();
3033    // Multiset equality via sort-and-compare (counts already match).
3034    live_bag.sort_unstable();
3035    expected_bag.sort_unstable();
3036    live_bag == expected_bag
3037}
3038
3039/// Re-hash the artifact at `path` and verify it still matches the digest
3040/// recorded at plan time. Returns `Err(Conflict)` if the bytes changed
3041/// between planning and execution (shrinks the TOCTOU window to what
3042/// `op deploy` itself has).
3043fn ensure_artifact_unchanged(path: &Path, expected: &str) -> Result<(), OpError> {
3044    let actual = super::bundle_stage::sha256_file(path).map_err(|source| OpError::Io {
3045        path: path.to_path_buf(),
3046        source,
3047    })?;
3048    if actual != expected {
3049        return Err(OpError::Conflict(format!(
3050            "artifact `{}` changed since the plan was computed (expected {}, found {}); \
3051             re-run apply",
3052            path.display(),
3053            short_digest(expected),
3054            short_digest(&actual),
3055        )));
3056    }
3057    Ok(())
3058}
3059
3060fn short_digest(digest: &str) -> &str {
3061    let end = digest.len().min("sha256:".len() + 8);
3062    &digest[..end]
3063}
3064
3065fn short_key_id(key_id: &str) -> &str {
3066    &key_id[..key_id.len().min(8)]
3067}
3068
3069fn binding_summary(binding: &Option<RouteBinding>) -> String {
3070    match binding {
3071        None => "default binding".to_string(),
3072        Some(rb) => {
3073            let mut parts = Vec::new();
3074            if !rb.path_prefixes.is_empty() {
3075                parts.push(rb.path_prefixes.join(","));
3076            }
3077            if !rb.hosts.is_empty() {
3078                parts.push(format!("hosts={}", rb.hosts.join(",")));
3079            }
3080            parts.push(format!(
3081                "tenant={}/{}",
3082                rb.tenant_selector.tenant, rb.tenant_selector.team
3083            ));
3084            parts.join(" ")
3085        }
3086    }
3087}
3088
3089fn render_plan(steps: &[ApplyStep], warnings: &[String], missing: &[MissingItem]) {
3090    eprintln!("plan ({} step(s)):", steps.len());
3091    for step in steps {
3092        eprintln!(
3093            "  {:<22} {:<40} {:<7} {}",
3094            step.kind.label(),
3095            step.key,
3096            step.action.as_str(),
3097            step.detail
3098        );
3099    }
3100    for m in missing {
3101        eprintln!(
3102            "  missing: {:<14} {:<40} {}",
3103            m.kind.as_str(),
3104            m.key,
3105            m.source
3106        );
3107    }
3108    for w in warnings {
3109        eprintln!("  warning: {w}");
3110    }
3111}
3112
3113fn report_json(
3114    ctx: &ApplyContext,
3115    steps: &[ApplyStep],
3116    mode: &str,
3117    verify: Option<Value>,
3118) -> Value {
3119    let changed = steps
3120        .iter()
3121        .filter(|s| s.action != ApplyAction::NoOp)
3122        .count();
3123    // Always-put rows: counted in `changed`, but not drift for `--check`
3124    // (see `ApplyAction::counts_as_drift`). Emitted in every mode so the
3125    // report schema doesn't vary by mode.
3126    let undiffable = steps
3127        .iter()
3128        .filter(|s| s.action == ApplyAction::Put)
3129        .count();
3130    let mut report = json!({
3131        "manifest_schema": ENV_MANIFEST_SCHEMA_V1,
3132        "environment_id": ctx.env_id.as_str(),
3133        "mode": mode,
3134        "steps": steps.iter().map(ApplyStep::to_json).collect::<Vec<_>>(),
3135        "changed": changed,
3136        "no_op": steps.len() - changed,
3137        "undiffable": undiffable,
3138        "missing": ctx.missing.iter().map(MissingItem::to_json).collect::<Vec<_>>(),
3139        "warnings": ctx.warnings,
3140    });
3141    if let Some(v) = verify {
3142        report["verify"] = v;
3143    }
3144    report
3145}
3146
3147#[cfg(test)]
3148mod tests {
3149    use super::*;
3150
3151    #[test]
3152    fn idempotency_keys_are_deterministic_and_state_sensitive() {
3153        let env_id = EnvId::try_from("local").unwrap();
3154        let a = derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h1");
3155        let b = derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h1");
3156        assert_eq!(a, b, "same inputs must derive the same key");
3157        assert_eq!(a.len(), 26, "ULID rendering is 26 chars");
3158        // Every input term must discriminate.
3159        let other_env = EnvId::try_from("prod").unwrap();
3160        assert_ne!(
3161            a,
3162            derive_idempotency_key(&other_env, "add-endpoint", "realbot-legal", "h1")
3163        );
3164        assert_ne!(
3165            a,
3166            derive_idempotency_key(&env_id, "link-endpoint", "realbot-legal", "h1")
3167        );
3168        assert_ne!(
3169            a,
3170            derive_idempotency_key(&env_id, "add-endpoint", "realbot-acct", "h1")
3171        );
3172        assert_ne!(
3173            a,
3174            derive_idempotency_key(&env_id, "add-endpoint", "realbot-legal", "h2")
3175        );
3176        // And the key must satisfy the spec shape.
3177        greentic_deploy_spec::IdempotencyKey::new(a).expect("derived key is spec-valid");
3178    }
3179
3180    #[test]
3181    fn degenerate_digests_are_not_real() {
3182        assert!(!digest_is_real("sha256:00"));
3183        assert!(!digest_is_real("sha256:"));
3184        assert!(!digest_is_real(""));
3185        assert!(!digest_is_real("md5:abcd"));
3186        assert!(digest_is_real("sha256:ab12cd34"));
3187    }
3188
3189    // --- LocalFsStore integration ---------------------------------------------
3190
3191    use crate::cli::tests_common::{
3192        bootstrap_env_trust_root, make_binding, make_bundle_deployment, make_env, make_revision,
3193        make_traffic_split,
3194    };
3195    use greentic_deploy_spec::{CapabilitySlot, RevisionLifecycle};
3196    use std::path::Path;
3197    use tempfile::tempdir;
3198
3199    fn seeded_store() -> (tempfile::TempDir, LocalFsStore) {
3200        let dir = tempdir().unwrap();
3201        let store = LocalFsStore::new(dir.path());
3202        store.save(&make_env("local")).unwrap();
3203        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
3204        bootstrap_env_trust_root(&env_dir);
3205        (dir, store)
3206    }
3207
3208    fn fixture() -> PathBuf {
3209        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
3210            .join("testdata/bundles/perf-smoke-bundle.gtbundle")
3211    }
3212
3213    fn write_manifest(dir: &Path, value: &Value) -> PathBuf {
3214        let path = dir.join("manifest.json");
3215        std::fs::write(&path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
3216        path
3217    }
3218
3219    #[test]
3220    fn resolved_artifact_digest_records_computed_when_undeclared() {
3221        let path = fixture();
3222        let digest = resolved_artifact_digest(&path, None, "bundle `x`").unwrap();
3223        assert_eq!(
3224            digest,
3225            super::super::bundle_stage::sha256_file(&path).unwrap()
3226        );
3227    }
3228
3229    #[test]
3230    fn resolved_artifact_digest_passes_when_declared_matches() {
3231        let path = fixture();
3232        let real = super::super::bundle_stage::sha256_file(&path).unwrap();
3233        let digest = resolved_artifact_digest(&path, Some(&real), "bundle `x`").unwrap();
3234        assert_eq!(digest, real);
3235    }
3236
3237    #[test]
3238    fn resolved_artifact_digest_rejects_declared_mismatch() {
3239        let path = fixture();
3240        let err =
3241            resolved_artifact_digest(&path, Some("sha256:deadbeef"), "bundle `x`").unwrap_err();
3242        assert_eq!(err.kind(), "conflict");
3243        assert!(err.to_string().contains("does not match"), "{err}");
3244    }
3245
3246    /// Full URI-only apply: no local `bundle_path`, just a `bundle_source_uri`.
3247    /// Apply fetches the bundle from ghcr, stages it, and records a revision
3248    /// carrying the OCI pull ref + a real digest a K8s worker boots from.
3249    /// Ignored by default (network + ghcr reachability).
3250    #[test]
3251    #[ignore = "network: applies a URI-only manifest that pulls from ghcr"]
3252    fn uri_only_apply_pulls_and_records_a_pullable_revision() {
3253        const URI: &str = "oci://ghcr.io/greenticai/greentic-demo-bundles/webchat-bot:v1";
3254        let (dir, store) = seeded_store();
3255        let manifest = serde_json::json!({
3256            "schema": ENV_MANIFEST_SCHEMA_V1,
3257            "environment": {"id": "local"},
3258            "bundles": [{
3259                "bundle_id": "remote",
3260                "bundle_source_uri": URI
3261            }]
3262        });
3263        let manifest_path = write_manifest(dir.path(), &manifest);
3264        let outcome = run_apply(&store, &manifest_path).expect("uri-only apply succeeds");
3265        assert_eq!(
3266            outcome.result["missing"].as_array().unwrap().len(),
3267            0,
3268            "no missing inputs: {}",
3269            outcome.result
3270        );
3271        let env = load_local(&store);
3272        assert_eq!(env.bundles.len(), 1);
3273        let dep = &env.bundles[0];
3274        let digest = live_revision_digest(&env, dep.deployment_id).expect("live revision");
3275        assert!(
3276            digest.starts_with("sha256:") && digest != "sha256:00",
3277            "real digest recorded: {digest}"
3278        );
3279        let rev = env
3280            .revisions
3281            .iter()
3282            .find(|r| r.deployment_id == dep.deployment_id)
3283            .expect("revision recorded");
3284        assert_eq!(
3285            rev.bundle_source_uri.as_deref(),
3286            Some(URI),
3287            "K8s worker pull ref recorded on the revision"
3288        );
3289    }
3290
3291    fn run_mode(
3292        store: &LocalFsStore,
3293        manifest_path: &Path,
3294        mode: ApplyMode,
3295    ) -> Result<OpOutcome, OpError> {
3296        let flags = OpFlags {
3297            schema_only: false,
3298            answers: Some(manifest_path.to_path_buf()),
3299        };
3300        apply(
3301            store,
3302            &flags,
3303            ApplyOptions {
3304                mode,
3305                ..ApplyOptions::default()
3306            },
3307        )
3308    }
3309
3310    fn run_apply(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3311        run_mode(store, manifest_path, ApplyMode::Apply)
3312    }
3313
3314    fn run_dry(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3315        run_mode(store, manifest_path, ApplyMode::DryRun)
3316    }
3317
3318    fn run_check(store: &LocalFsStore, manifest_path: &Path) -> Result<OpOutcome, OpError> {
3319        run_mode(store, manifest_path, ApplyMode::Check)
3320    }
3321
3322    fn load_local(store: &LocalFsStore) -> Environment {
3323        store.load(&EnvId::try_from("local").unwrap()).unwrap()
3324    }
3325
3326    /// The two-dept-shaped manifest: one bundle with a tenant-scoped path
3327    /// binding, one telegram endpoint linking it, plus a welcome flow.
3328    fn full_manifest(bundle_path: &Path) -> Value {
3329        json!({
3330            "schema": ENV_MANIFEST_SCHEMA_V1,
3331            "environment": {"id": "local"},
3332            "bundles": [{
3333                "bundle_id": "quickstart",
3334                "bundle_path": bundle_path,
3335                "route_binding": {
3336                    "path_prefixes": ["/legal"],
3337                    "tenant_selector": {"tenant": "legal", "team": "default"}
3338                }
3339            }],
3340            "messaging_endpoints": [{
3341                "name": "legal-bot",
3342                "provider_type": "messaging.telegram.bot",
3343                "links": ["quickstart"],
3344                "welcome_flow": {
3345                    "bundle_id": "quickstart",
3346                    "pack_id": "perf-smoke-pack",
3347                    "flow_id": "main"
3348                }
3349            }]
3350        })
3351    }
3352
3353    fn step_actions(outcome: &Value) -> Vec<(String, String)> {
3354        outcome["steps"]
3355            .as_array()
3356            .expect("steps array")
3357            .iter()
3358            .map(|s| {
3359                (
3360                    s["kind"].as_str().unwrap().to_string(),
3361                    s["action"].as_str().unwrap().to_string(),
3362                )
3363            })
3364            .collect()
3365    }
3366
3367    #[test]
3368    fn fresh_apply_then_noop_reapply() {
3369        let (dir, store) = seeded_store();
3370        let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3371
3372        let outcome = run_apply(&store, &manifest_path).expect("first apply succeeds");
3373        let result = outcome.result;
3374        assert_eq!(result["mode"], "apply");
3375        // deploy + add-endpoint + link + welcome-flow change; ensure-env no-ops.
3376        assert_eq!(result["changed"], 4, "result: {result}");
3377        assert_eq!(
3378            result["verify"]["failures"].as_array().unwrap().len(),
3379            0,
3380            "verify must pass: {result}"
3381        );
3382
3383        let env = load_local(&store);
3384        assert_eq!(env.bundles.len(), 1);
3385        let dep = &env.bundles[0];
3386        assert_eq!(dep.route_binding.path_prefixes, vec!["/legal".to_string()]);
3387        assert_eq!(dep.route_binding.tenant_selector.tenant, "legal");
3388        let live = live_revision_digest(&env, dep.deployment_id).expect("live revision");
3389        assert_eq!(
3390            live,
3391            super::super::bundle_stage::sha256_file(&fixture()).unwrap(),
3392            "live revision must carry the artifact digest"
3393        );
3394        assert_eq!(env.messaging_endpoints.len(), 1);
3395        let ep = &env.messaging_endpoints[0];
3396        assert_eq!(ep.display_name, "legal-bot");
3397        assert_eq!(ep.provider_id, "legal-bot", "provider_id = manifest name");
3398        assert_eq!(
3399            ep.linked_bundles
3400                .iter()
3401                .map(|b| b.as_str())
3402                .collect::<Vec<_>>(),
3403            vec!["quickstart"]
3404        );
3405        let wf = ep.welcome_flow.as_ref().expect("welcome flow set");
3406        assert_eq!(wf.flow_id, "main");
3407        assert!(
3408            ep.webhook_secret_ref.is_some(),
3409            "telegram-class endpoint gets an auto-provisioned webhook secret"
3410        );
3411
3412        // Unchanged re-apply: every step is a visible no-op, nothing re-staged.
3413        let revisions_before = env.revisions.len();
3414        let second = run_apply(&store, &manifest_path).expect("re-apply succeeds");
3415        assert_eq!(second.result["changed"], 0, "result: {}", second.result);
3416        let env = load_local(&store);
3417        assert_eq!(
3418            env.revisions.len(),
3419            revisions_before,
3420            "no-op re-apply must not stage a new revision"
3421        );
3422    }
3423
3424    #[test]
3425    fn dry_run_mutates_nothing() {
3426        let (dir, store) = seeded_store();
3427        let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3428
3429        let outcome = run_dry(&store, &manifest_path).expect("dry-run succeeds");
3430        assert_eq!(outcome.result["mode"], "dry-run");
3431        assert_eq!(outcome.result["changed"], 4);
3432        assert!(
3433            outcome.result.get("verify").is_none(),
3434            "dry-run must not verify (nothing executed)"
3435        );
3436
3437        let env = load_local(&store);
3438        assert!(env.bundles.is_empty(), "dry-run must not deploy");
3439        assert!(
3440            env.messaging_endpoints.is_empty(),
3441            "dry-run must not add endpoints"
3442        );
3443    }
3444
3445    // --- §9 follow-up: --check (CI convergence gate) ----------------------------
3446
3447    #[test]
3448    fn check_fails_on_pending_diff_and_mutates_nothing() {
3449        let (dir, store) = seeded_store();
3450        let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3451
3452        let err = run_check(&store, &manifest_path).unwrap_err();
3453        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
3454        let msg = err.to_string();
3455        assert!(msg.contains("not converged"), "{msg}");
3456        assert!(msg.contains("4 pending change(s)"), "{msg}");
3457
3458        let env = load_local(&store);
3459        assert!(env.bundles.is_empty(), "--check must not deploy");
3460        assert!(
3461            env.messaging_endpoints.is_empty(),
3462            "--check must not add endpoints"
3463        );
3464    }
3465
3466    #[test]
3467    fn check_passes_on_converged_env() {
3468        let (dir, store) = seeded_store();
3469        let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3470        run_apply(&store, &manifest_path).expect("apply succeeds");
3471
3472        let outcome = run_check(&store, &manifest_path).expect("check passes on converged env");
3473        assert_eq!(outcome.result["mode"], "check");
3474        assert_eq!(outcome.result["changed"], 0, "result: {}", outcome.result);
3475        assert_eq!(outcome.result["undiffable"], 0);
3476        assert!(
3477            outcome.result.get("verify").is_none(),
3478            "check must not verify (nothing executed)"
3479        );
3480    }
3481
3482    #[test]
3483    fn binding_only_change_plans_update_without_restage() {
3484        let (dir, store) = seeded_store();
3485        let manifest_path = write_manifest(dir.path(), &full_manifest(&fixture()));
3486        run_apply(&store, &manifest_path).expect("first apply");
3487        let revisions_before = load_local(&store).revisions.len();
3488
3489        // Same artifact, different path prefix → exactly one update-bundle;
3490        // the deploy step must NOT appear (digest matches → no re-stage).
3491        let mut changed = full_manifest(&fixture());
3492        changed["bundles"][0]["route_binding"]["path_prefixes"] = json!(["/law"]);
3493        let manifest_path = write_manifest(dir.path(), &changed);
3494
3495        let plan = run_dry(&store, &manifest_path).expect("dry-run");
3496        let actions = step_actions(&plan.result);
3497        assert!(
3498            actions.contains(&("update-bundle".to_string(), "update".to_string())),
3499            "plan: {actions:?}"
3500        );
3501        assert!(
3502            actions
3503                .iter()
3504                .all(|(kind, action)| kind != "deploy-bundle" || action == "no-op"),
3505            "digest match must not re-deploy: {actions:?}"
3506        );
3507
3508        let outcome = run_apply(&store, &manifest_path).expect("apply binding change");
3509        assert_eq!(outcome.result["changed"], 1, "{}", outcome.result);
3510        let env = load_local(&store);
3511        assert_eq!(
3512            env.bundles[0].route_binding.path_prefixes,
3513            vec!["/law".to_string()]
3514        );
3515        assert_eq!(
3516            env.revisions.len(),
3517            revisions_before,
3518            "binding-only change must not stage a new revision"
3519        );
3520    }
3521
3522    // --- G2/G3: revenue_share + status ---
3523
3524    /// Minimal single-revision manifest with no binding/endpoints — the base
3525    /// for the metadata-reconcile tests.
3526    fn plain_bundle_manifest() -> Value {
3527        json!({
3528            "schema": ENV_MANIFEST_SCHEMA_V1,
3529            "environment": {"id": "local"},
3530            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3531        })
3532    }
3533
3534    #[test]
3535    fn status_reconciles_against_existing_deployment() {
3536        let (dir, store) = seeded_store();
3537        let base = plain_bundle_manifest();
3538        let base_path = write_manifest(dir.path(), &base);
3539        run_apply(&store, &base_path).expect("first apply");
3540        assert_eq!(
3541            load_local(&store).bundles[0].status,
3542            BundleDeploymentStatus::Active
3543        );
3544
3545        // Re-apply with status: paused → one update-bundle, no re-stage.
3546        let mut paused = base.clone();
3547        paused["bundles"][0]["status"] = json!("paused");
3548        let paused_path = write_manifest(dir.path(), &paused);
3549        let plan = run_dry(&store, &paused_path).expect("dry-run");
3550        let actions = step_actions(&plan.result);
3551        assert!(
3552            actions.contains(&("update-bundle".to_string(), "update".to_string())),
3553            "status change must plan an update-bundle: {actions:?}"
3554        );
3555        assert!(
3556            actions
3557                .iter()
3558                .all(|(k, a)| k != "deploy-bundle" || a == "no-op"),
3559            "status change must not re-stage: {actions:?}"
3560        );
3561
3562        run_apply(&store, &paused_path).expect("apply pause");
3563        assert_eq!(
3564            load_local(&store).bundles[0].status,
3565            BundleDeploymentStatus::Paused
3566        );
3567
3568        // Idempotent: re-applying the paused manifest changes nothing.
3569        let plan2 = run_dry(&store, &paused_path).expect("dry-run 2");
3570        assert_eq!(plan2.result["changed"], 0, "{}", plan2.result);
3571    }
3572
3573    #[test]
3574    fn revenue_share_reconciles_against_existing_deployment() {
3575        let (dir, store) = seeded_store();
3576        let base = plain_bundle_manifest();
3577        let base_path = write_manifest(dir.path(), &base);
3578        run_apply(&store, &base_path).expect("first apply");
3579        // Default split = greentic@10000.
3580        assert_eq!(load_local(&store).bundles[0].revenue_share.len(), 1);
3581
3582        let mut split = base.clone();
3583        split["bundles"][0]["revenue_share"] = json!([
3584            {"party_id": "greentic", "basis_points": 7000},
3585            {"party_id": "partner", "basis_points": 3000}
3586        ]);
3587        let split_path = write_manifest(dir.path(), &split);
3588        let plan = run_dry(&store, &split_path).expect("dry-run");
3589        assert!(
3590            step_actions(&plan.result)
3591                .contains(&("update-bundle".to_string(), "update".to_string())),
3592            "revenue_share change must plan an update-bundle"
3593        );
3594
3595        run_apply(&store, &split_path).expect("apply revenue split");
3596        let env = load_local(&store);
3597        let shares = &env.bundles[0].revenue_share;
3598        assert_eq!(shares.len(), 2);
3599        assert_eq!(shares[0].party_id.as_str(), "greentic");
3600        assert_eq!(shares[0].basis_points, 7000);
3601        assert_eq!(shares[1].party_id.as_str(), "partner");
3602        assert_eq!(shares[1].basis_points, 3000);
3603
3604        // Idempotent.
3605        let plan2 = run_dry(&store, &split_path).expect("dry-run 2");
3606        assert_eq!(plan2.result["changed"], 0, "{}", plan2.result);
3607    }
3608
3609    #[test]
3610    fn revenue_share_threaded_into_fresh_deploy_single_apply() {
3611        let (dir, store) = seeded_store();
3612        let mut manifest = plain_bundle_manifest();
3613        manifest["bundles"][0]["revenue_share"] = json!([
3614            {"party_id": "greentic", "basis_points": 6000},
3615            {"party_id": "partner", "basis_points": 4000}
3616        ]);
3617        let p = write_manifest(dir.path(), &manifest);
3618        // A SINGLE apply must converge — verify asserts revenue_share, so this
3619        // proves it is threaded into the create path (not deferred).
3620        run_apply(&store, &p).expect("fresh apply with revenue_share must verify");
3621        let shares = &load_local(&store).bundles[0].revenue_share;
3622        assert_eq!(shares.len(), 2);
3623        assert_eq!(shares[1].party_id.as_str(), "partner");
3624        assert_eq!(shares[1].basis_points, 4000);
3625        assert_eq!(run_dry(&store, &p).expect("dry").result["changed"], 0);
3626    }
3627
3628    #[test]
3629    fn status_on_fresh_create_defers_one_apply_without_failing_verify() {
3630        let (dir, store) = seeded_store();
3631        let mut manifest = plain_bundle_manifest();
3632        manifest["bundles"][0]["status"] = json!("paused");
3633        let p = write_manifest(dir.path(), &manifest);
3634        // First apply creates the deployment (born `active`); verify must NOT
3635        // fail for the not-yet-applied `paused` status (reconcile-only).
3636        run_apply(&store, &p).expect("fresh apply with status must not fail verify");
3637        assert_eq!(
3638            load_local(&store).bundles[0].status,
3639            BundleDeploymentStatus::Active
3640        );
3641        // Second apply: the deployment now pre-dates the apply → status
3642        // reconciles to paused, single-apply from here.
3643        run_apply(&store, &p).expect("second apply pauses");
3644        assert_eq!(
3645            load_local(&store).bundles[0].status,
3646            BundleDeploymentStatus::Paused
3647        );
3648    }
3649
3650    #[test]
3651    fn degenerate_live_digest_fails_toward_redeploy() {
3652        let (dir, store) = seeded_store();
3653        // Seed a pre-digest deployment by hand: live revision carries the
3654        // `sha256:00` placeholder.
3655        let mut env = make_env("local");
3656        let dep = make_bundle_deployment("local", "quickstart");
3657        let rev = make_revision(
3658            "local",
3659            "quickstart",
3660            &dep.deployment_id,
3661            1,
3662            RevisionLifecycle::Ready,
3663        );
3664        env.traffic_splits.push(make_traffic_split(
3665            "local",
3666            "quickstart",
3667            &dep.deployment_id,
3668            &rev.revision_id,
3669            "seed",
3670        ));
3671        env.bundles.push(dep);
3672        env.revisions.push(rev);
3673        store.save(&env).unwrap();
3674
3675        // No route_binding in the manifest → the only pending step must be
3676        // the re-deploy (unknown digest fails toward deploy, never toward skip).
3677        let manifest = json!({
3678            "schema": ENV_MANIFEST_SCHEMA_V1,
3679            "environment": {"id": "local"},
3680            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3681        });
3682        let manifest_path = write_manifest(dir.path(), &manifest);
3683        let plan = run_dry(&store, &manifest_path).expect("dry-run");
3684        let actions = step_actions(&plan.result);
3685        assert!(
3686            actions.contains(&("deploy-bundle".to_string(), "update".to_string())),
3687            "degenerate digest must plan a re-deploy: {actions:?}"
3688        );
3689        assert_eq!(plan.result["changed"], 1, "{}", plan.result);
3690    }
3691
3692    #[test]
3693    fn endpoint_ambiguity_is_an_error() {
3694        let (dir, store) = seeded_store();
3695        // Two endpoints with the same (provider_type, display_name) pair but
3696        // distinct provider_ids — legal at the store level, ambiguous for the
3697        // manifest's natural key.
3698        for provider_id in ["bot-a", "bot-b"] {
3699            super::super::messaging::add(
3700                &store,
3701                &OpFlags::default(),
3702                Some(EndpointAddPayload {
3703                    environment_id: "local".to_string(),
3704                    provider_id: provider_id.to_string(),
3705                    provider_type: "messaging.telegram.bot".to_string(),
3706                    display_name: "legal-bot".to_string(),
3707                    secret_refs: Vec::new(),
3708                    webhook_secret_ref: None,
3709                    idempotency_key: None,
3710                    updated_by: "test".to_string(),
3711                }),
3712            )
3713            .expect("seed endpoint");
3714        }
3715        let manifest = json!({
3716            "schema": ENV_MANIFEST_SCHEMA_V1,
3717            "environment": {"id": "local"},
3718            "messaging_endpoints": [
3719                {"name": "legal-bot", "provider_type": "messaging.telegram.bot"}
3720            ]
3721        });
3722        let manifest_path = write_manifest(dir.path(), &manifest);
3723        let err = run_apply(&store, &manifest_path).unwrap_err();
3724        match err {
3725            OpError::Conflict(msg) => {
3726                assert!(msg.contains("refuses to guess"), "got: {msg}")
3727            }
3728            other => panic!("expected Conflict, got {other:?}"),
3729        }
3730    }
3731
3732    #[test]
3733    fn provider_type_mismatch_on_matched_name_is_an_error() {
3734        let (dir, store) = seeded_store();
3735        super::super::messaging::add(
3736            &store,
3737            &OpFlags::default(),
3738            Some(EndpointAddPayload {
3739                environment_id: "local".to_string(),
3740                provider_id: "legal-bot".to_string(),
3741                provider_type: "messaging.teams.bot".to_string(),
3742                display_name: "legal-bot".to_string(),
3743                secret_refs: Vec::new(),
3744                webhook_secret_ref: None,
3745                idempotency_key: None,
3746                updated_by: "test".to_string(),
3747            }),
3748        )
3749        .expect("seed endpoint");
3750        let manifest = json!({
3751            "schema": ENV_MANIFEST_SCHEMA_V1,
3752            "environment": {"id": "local"},
3753            "messaging_endpoints": [
3754                {"name": "legal-bot", "provider_type": "messaging.telegram.bot"}
3755            ]
3756        });
3757        let manifest_path = write_manifest(dir.path(), &manifest);
3758        let err = run_apply(&store, &manifest_path).unwrap_err();
3759        match err {
3760            OpError::Conflict(msg) => assert!(msg.contains("repurpose"), "got: {msg}"),
3761            other => panic!("expected Conflict, got {other:?}"),
3762        }
3763    }
3764
3765    #[test]
3766    fn link_to_unknown_bundle_is_an_error() {
3767        let (dir, store) = seeded_store();
3768        let manifest = json!({
3769            "schema": ENV_MANIFEST_SCHEMA_V1,
3770            "environment": {"id": "local"},
3771            "messaging_endpoints": [{
3772                "name": "legal-bot",
3773                "provider_type": "messaging.telegram.bot",
3774                "links": ["ghost"]
3775            }]
3776        });
3777        let manifest_path = write_manifest(dir.path(), &manifest);
3778        let err = run_apply(&store, &manifest_path).unwrap_err();
3779        match err {
3780            OpError::InvalidArgument(msg) => assert!(msg.contains("ghost"), "got: {msg}"),
3781            other => panic!("expected InvalidArgument, got {other:?}"),
3782        }
3783        // Validation failures must not mutate anything.
3784        assert!(load_local(&store).messaging_endpoints.is_empty());
3785    }
3786
3787    #[test]
3788    fn partial_failure_resumes_on_reapply() {
3789        let (dir, store) = seeded_store();
3790        // Second bundle's artifact is garbage — validation passes (it IS a
3791        // file with a digest), execution fails at the stage step.
3792        let broken = dir.path().join("broken.gtbundle");
3793        std::fs::write(&broken, b"not a squashfs").unwrap();
3794        let manifest = json!({
3795            "schema": ENV_MANIFEST_SCHEMA_V1,
3796            "environment": {"id": "local"},
3797            "bundles": [
3798                {"bundle_id": "quickstart", "bundle_path": fixture()},
3799                {"bundle_id": "broken", "bundle_path": broken}
3800            ]
3801        });
3802        let manifest_path = write_manifest(dir.path(), &manifest);
3803        run_apply(&store, &manifest_path).expect_err("garbage artifact must fail the apply");
3804
3805        // Fail-fast left the store valid — the store is the checkpoint. The
3806        // first bundle is fully routed; the broken one failed at the stage
3807        // step AFTER its `bundles add` landed, so its deployment record
3808        // exists but carries no live traffic split.
3809        let env = load_local(&store);
3810        assert_eq!(env.bundles.len(), 2, "both deployment records exist");
3811        assert_eq!(
3812            env.traffic_splits.len(),
3813            1,
3814            "only the first bundle is routed"
3815        );
3816        assert_eq!(
3817            env.traffic_splits[0].bundle_id.as_str(),
3818            "quickstart",
3819            "the routed split belongs to the bundle that staged successfully"
3820        );
3821
3822        // Fix the manifest (point `broken` at the real artifact) and re-run:
3823        // the completed bundle no-ops, only the remainder executes.
3824        let fixed = json!({
3825            "schema": ENV_MANIFEST_SCHEMA_V1,
3826            "environment": {"id": "local"},
3827            "bundles": [
3828                {"bundle_id": "quickstart", "bundle_path": fixture()},
3829                {"bundle_id": "broken", "bundle_path": fixture()}
3830            ]
3831        });
3832        let manifest_path = write_manifest(dir.path(), &fixed);
3833        let outcome = run_apply(&store, &manifest_path).expect("resume succeeds");
3834        assert_eq!(outcome.result["changed"], 1, "{}", outcome.result);
3835        let env = load_local(&store);
3836        assert_eq!(env.bundles.len(), 2);
3837    }
3838
3839    #[test]
3840    fn nonexistent_nonlocal_env_gives_clear_error() {
3841        // `env apply` reconciles an existing named env but does not bootstrap
3842        // one (only `local` is auto-created). Applying a manifest for a
3843        // not-yet-created named env must fail at plan time with a clear message
3844        // pointing the user at `op env create <id>`.
3845        let dir = tempdir().unwrap();
3846        let store = LocalFsStore::new(dir.path());
3847        let manifest = json!({
3848            "schema": ENV_MANIFEST_SCHEMA_V1,
3849            "environment": {
3850                "id": "prod",
3851                "name": "Production",
3852                "region": "us-east-1",
3853                "tenant_org_id": "org-42",
3854            }
3855        });
3856        let manifest_path = write_manifest(dir.path(), &manifest);
3857        let err = run_apply(&store, &manifest_path).unwrap_err();
3858        match err {
3859            OpError::NotFound(msg) => {
3860                assert!(
3861                    msg.contains("not found")
3862                        && msg.contains("op env create prod")
3863                        && msg.contains("does not create one"),
3864                    "got: {msg}"
3865                );
3866            }
3867            other => panic!("expected NotFound, got {other:?}"),
3868        }
3869    }
3870
3871    #[test]
3872    fn link_satisfied_only_by_env_warns() {
3873        let (dir, store) = seeded_store();
3874        // Deploy a bundle imperatively, then link to it from a manifest that
3875        // does not declare it: a warning, not an error (layered manifests).
3876        super::super::deploy::deploy(
3877            &store,
3878            &OpFlags::default(),
3879            Some(BundleDeployPayload {
3880                environment_id: "local".to_string(),
3881                bundle_id: "preexisting".to_string(),
3882                customer_id: None,
3883                bundle_path: Some(fixture()),
3884                bundle_source_uri: None,
3885                remote_pins: None,
3886                idempotency_key: None,
3887                config_overrides: None,
3888                route_binding: None,
3889                revenue_share: None,
3890            }),
3891        )
3892        .expect("imperative deploy");
3893        let manifest = json!({
3894            "schema": ENV_MANIFEST_SCHEMA_V1,
3895            "environment": {"id": "local"},
3896            "messaging_endpoints": [{
3897                "name": "legal-bot",
3898                "provider_type": "messaging.telegram.bot",
3899                "links": ["preexisting"]
3900            }]
3901        });
3902        let manifest_path = write_manifest(dir.path(), &manifest);
3903        let outcome = run_apply(&store, &manifest_path).expect("apply succeeds with warning");
3904        let warnings = outcome.result["warnings"].as_array().unwrap();
3905        assert!(
3906            warnings
3907                .iter()
3908                .any(|w| w.as_str().unwrap().contains("pre-existing")),
3909            "warnings: {warnings:?}"
3910        );
3911        let env = load_local(&store);
3912        assert_eq!(env.messaging_endpoints[0].linked_bundles.len(), 1);
3913    }
3914
3915    // --- Fix 1: customer-aware bundle matching ---
3916
3917    #[test]
3918    fn cross_customer_deployment_is_rejected() {
3919        let (dir, store) = seeded_store();
3920        // Seed a deployment owned by "other-customer".
3921        let mut env = make_env("local");
3922        let mut dep = make_bundle_deployment("local", "quickstart");
3923        dep.customer_id = CustomerId::new("other-customer");
3924        env.bundles.push(dep);
3925        store.save(&env).unwrap();
3926
3927        // Manifest declares the same bundle_id but resolves to the default
3928        // customer "local-dev" → Conflict.
3929        let manifest = json!({
3930            "schema": ENV_MANIFEST_SCHEMA_V1,
3931            "environment": {"id": "local"},
3932            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
3933        });
3934        let manifest_path = write_manifest(dir.path(), &manifest);
3935        let err = run_apply(&store, &manifest_path).unwrap_err();
3936        match err {
3937            OpError::Conflict(msg) => {
3938                assert!(msg.contains("other-customer"), "got: {msg}");
3939                assert!(msg.contains("local-dev"), "got: {msg}");
3940            }
3941            other => panic!("expected Conflict, got {other:?}"),
3942        }
3943        // No mutation.
3944        let env = load_local(&store);
3945        assert!(env.revisions.is_empty());
3946    }
3947
3948    // --- Fix 2: pre-deploy artifact re-hash ---
3949
3950    #[test]
3951    fn ensure_artifact_unchanged_catches_modification() {
3952        let dir = tempdir().unwrap();
3953        let path = dir.path().join("test.bin");
3954        std::fs::write(&path, b"original content").unwrap();
3955        let digest = super::super::bundle_stage::sha256_file(&path).unwrap();
3956
3957        // Same bytes → Ok.
3958        ensure_artifact_unchanged(&path, &digest).expect("unchanged file must pass");
3959
3960        // Mutate → Err(Conflict).
3961        std::fs::write(&path, b"tampered content").unwrap();
3962        let err = ensure_artifact_unchanged(&path, &digest).unwrap_err();
3963        match err {
3964            OpError::Conflict(msg) => {
3965                assert!(msg.contains("changed since the plan"), "got: {msg}")
3966            }
3967            other => panic!("expected Conflict, got {other:?}"),
3968        }
3969    }
3970
3971    // --- Fix 3: strict convergence check ---
3972
3973    #[test]
3974    fn mixed_split_is_not_converged_plans_redeploy() {
3975        use greentic_deploy_spec::TrafficSplitEntry;
3976
3977        let (dir, store) = seeded_store();
3978        let real_digest = super::super::bundle_stage::sha256_file(&fixture()).unwrap();
3979
3980        let mut env = make_env("local");
3981        let dep = make_bundle_deployment("local", "quickstart");
3982        let mut rev1 = make_revision(
3983            "local",
3984            "quickstart",
3985            &dep.deployment_id,
3986            1,
3987            RevisionLifecycle::Ready,
3988        );
3989        rev1.bundle_digest = real_digest;
3990        let rev2 = make_revision(
3991            "local",
3992            "quickstart",
3993            &dep.deployment_id,
3994            2,
3995            RevisionLifecycle::Ready,
3996        );
3997        // Two-entry split: 6000/4000 — sum = 10_000 (valid).
3998        let mut split = make_traffic_split(
3999            "local",
4000            "quickstart",
4001            &dep.deployment_id,
4002            &rev1.revision_id,
4003            "seed",
4004        );
4005        split.entries[0].weight_bps = 6_000;
4006        split.entries.push(TrafficSplitEntry {
4007            revision_id: rev2.revision_id,
4008            weight_bps: 4_000,
4009        });
4010        env.bundles.push(dep);
4011        env.revisions.push(rev1);
4012        env.revisions.push(rev2);
4013        env.traffic_splits.push(split);
4014        store.save(&env).unwrap();
4015
4016        // Manifest: same bundle, same artifact (top entry digest matches) —
4017        // but the split is mixed, so it MUST plan a re-deploy, not a no-op.
4018        let manifest = json!({
4019            "schema": ENV_MANIFEST_SCHEMA_V1,
4020            "environment": {"id": "local"},
4021            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}]
4022        });
4023        let manifest_path = write_manifest(dir.path(), &manifest);
4024        let plan = run_dry(&store, &manifest_path).expect("dry-run");
4025        let actions = step_actions(&plan.result);
4026        assert!(
4027            actions.contains(&("deploy-bundle".to_string(), "update".to_string())),
4028            "mixed split must plan a re-deploy: {actions:?}"
4029        );
4030        assert!(plan.result["changed"].as_u64().unwrap() >= 1);
4031    }
4032
4033    // --- split_converged is multiset equality, not set containment ---
4034
4035    /// Build a `ResolvedRevision` with the given digest + weight
4036    /// (`bundle_source_uri` defaults to `None`).
4037    fn resolved_rev(name: &str, digest: &str, weight_bps: u32) -> ResolvedRevision {
4038        ResolvedRevision {
4039            spec: ManifestRevision {
4040                name: name.to_string(),
4041                bundle_path: PathBuf::from(format!("{name}.gtbundle")),
4042                weight_percent: None,
4043                weight_bps: Some(weight_bps),
4044                drain_seconds: None,
4045                abort_metrics: Vec::new(),
4046                bundle_source_uri: None,
4047                bundle_digest: None,
4048            },
4049            resolved_path: PathBuf::from(format!("{name}.gtbundle")),
4050            digest: digest.to_string(),
4051            weight_bps,
4052        }
4053    }
4054
4055    /// Seed an env with a two-entry split over two revisions, returning the
4056    /// env and its deployment id. Each `(digest, weight_bps)` is applied to a
4057    /// distinct revision.
4058    fn env_with_two_entry_split(live: [(&str, u32); 2]) -> (Environment, DeploymentId) {
4059        use greentic_deploy_spec::TrafficSplitEntry;
4060        let mut env = make_env("local");
4061        let dep = make_bundle_deployment("local", "quickstart");
4062        let dep_id = dep.deployment_id;
4063        let mut rev1 = make_revision("local", "quickstart", &dep_id, 1, RevisionLifecycle::Ready);
4064        rev1.bundle_digest = live[0].0.to_string();
4065        let mut rev2 = make_revision("local", "quickstart", &dep_id, 2, RevisionLifecycle::Ready);
4066        rev2.bundle_digest = live[1].0.to_string();
4067        let mut split =
4068            make_traffic_split("local", "quickstart", &dep_id, &rev1.revision_id, "seed");
4069        split.entries[0].weight_bps = live[0].1;
4070        split.entries.push(TrafficSplitEntry {
4071            revision_id: rev2.revision_id,
4072            weight_bps: live[1].1,
4073        });
4074        env.bundles.push(dep);
4075        env.revisions.push(rev1);
4076        env.revisions.push(rev2);
4077        env.traffic_splits.push(split);
4078        (env, dep_id)
4079    }
4080
4081    #[test]
4082    fn split_converged_matches_exact_multiset() {
4083        let (env, dep_id) =
4084            env_with_two_entry_split([("sha256:aaaa11", 5_000), ("sha256:bbbb22", 5_000)]);
4085        let expected = [
4086            resolved_rev("a", "sha256:aaaa11", 5_000),
4087            resolved_rev("b", "sha256:bbbb22", 5_000),
4088        ];
4089        assert!(
4090            split_converged(&env, dep_id, &expected),
4091            "identical (digest, weight) bag must converge"
4092        );
4093        // Order-independence: same bag, swapped expected order.
4094        let swapped = [
4095            resolved_rev("b", "sha256:bbbb22", 5_000),
4096            resolved_rev("a", "sha256:aaaa11", 5_000),
4097        ];
4098        assert!(split_converged(&env, dep_id, &swapped));
4099    }
4100
4101    #[test]
4102    fn split_converged_rejects_duplicate_digest_mismatch() {
4103        // Live bag has TWO distinct digests; expected names the SAME digest
4104        // twice. A set-containment check (the old `.any()`) would falsely
4105        // report convergence; a multiset check must reject it — otherwise the
4106        // desired split is silently never applied.
4107        let (env, dep_id) =
4108            env_with_two_entry_split([("sha256:aaaa11", 5_000), ("sha256:bbbb22", 5_000)]);
4109        let expected = [
4110            resolved_rev("a", "sha256:aaaa11", 5_000),
4111            resolved_rev("a-dup", "sha256:aaaa11", 5_000),
4112        ];
4113        assert!(
4114            !split_converged(&env, dep_id, &expected),
4115            "duplicate-digest expected bag must NOT match a two-distinct-digest live split"
4116        );
4117    }
4118
4119    #[test]
4120    fn split_converged_rejects_weight_mismatch() {
4121        let (env, dep_id) =
4122            env_with_two_entry_split([("sha256:aaaa11", 6_000), ("sha256:bbbb22", 4_000)]);
4123        let expected = [
4124            resolved_rev("a", "sha256:aaaa11", 5_000),
4125            resolved_rev("b", "sha256:bbbb22", 5_000),
4126        ];
4127        assert!(
4128            !split_converged(&env, dep_id, &expected),
4129            "same digests but different weights must not converge"
4130        );
4131    }
4132
4133    #[test]
4134    fn split_converged_rejects_placeholder_digest() {
4135        // A live revision still carrying the staging placeholder digest is
4136        // not a real deploy — convergence must fail.
4137        let (env, dep_id) =
4138            env_with_two_entry_split([("sha256:00", 5_000), ("sha256:bbbb22", 5_000)]);
4139        let expected = [
4140            resolved_rev("a", "sha256:00", 5_000),
4141            resolved_rev("b", "sha256:bbbb22", 5_000),
4142        ];
4143        assert!(
4144            !split_converged(&env, dep_id, &expected),
4145            "placeholder live digest must not be treated as converged"
4146        );
4147    }
4148
4149    // --- Fix 4: deploy BEFORE binding update ---
4150
4151    #[test]
4152    fn deploy_precedes_binding_update_when_both_differ() {
4153        let (dir, store) = seeded_store();
4154        // Seed: deployment with degenerate digest (needs deploy) + binding
4155        // that differs from the manifest's.
4156        let mut env = make_env("local");
4157        let dep = make_bundle_deployment("local", "quickstart");
4158        let rev = make_revision(
4159            "local",
4160            "quickstart",
4161            &dep.deployment_id,
4162            1,
4163            RevisionLifecycle::Ready,
4164        );
4165        env.traffic_splits.push(make_traffic_split(
4166            "local",
4167            "quickstart",
4168            &dep.deployment_id,
4169            &rev.revision_id,
4170            "seed",
4171        ));
4172        env.bundles.push(dep);
4173        env.revisions.push(rev);
4174        store.save(&env).unwrap();
4175
4176        // Manifest with the fixture path AND a different binding (/legal).
4177        let manifest = full_manifest(&fixture());
4178        let manifest_path = write_manifest(dir.path(), &manifest);
4179        let plan = run_dry(&store, &manifest_path).expect("dry-run");
4180        let steps = plan.result["steps"].as_array().expect("steps");
4181
4182        // Find indices of deploy-bundle (update) and update-bundle (update).
4183        let deploy_idx = steps
4184            .iter()
4185            .position(|s| s["kind"] == "deploy-bundle" && s["action"] == "update")
4186            .expect("deploy-bundle update step");
4187        let update_idx = steps
4188            .iter()
4189            .position(|s| s["kind"] == "update-bundle" && s["action"] == "update")
4190            .expect("update-bundle update step");
4191
4192        assert!(
4193            deploy_idx < update_idx,
4194            "deploy-bundle (idx {deploy_idx}) must precede update-bundle (idx {update_idx}): \
4195             {steps:?}"
4196        );
4197    }
4198
4199    // --- PR-2: secrets[] -------------------------------------------------------
4200
4201    /// [`seeded_store`] plus the default dev-store secrets binding `env init`
4202    /// would create (`make_env` binds no env-packs).
4203    fn seeded_store_with_dev_secrets() -> (tempfile::TempDir, LocalFsStore) {
4204        let (dir, store) = seeded_store();
4205        let mut env = load_local(&store);
4206        env.packs.push(make_binding(
4207            CapabilitySlot::Secrets,
4208            "greentic.secrets.dev-store@1.0.0",
4209        ));
4210        store.save(&env).unwrap();
4211        (dir, store)
4212    }
4213
4214    fn run_with_lookup(
4215        store: &LocalFsStore,
4216        manifest_path: &Path,
4217        mode: ApplyMode,
4218        lookup: &dyn Fn(&str) -> Option<String>,
4219    ) -> Result<OpOutcome, OpError> {
4220        run_with_lookup_and_prompter(store, manifest_path, mode, lookup, None)
4221    }
4222
4223    fn run_with_lookup_and_prompter(
4224        store: &LocalFsStore,
4225        manifest_path: &Path,
4226        mode: ApplyMode,
4227        lookup: &dyn Fn(&str) -> Option<String>,
4228        prompter: Option<&SecretPrompter>,
4229    ) -> Result<OpOutcome, OpError> {
4230        let flags = OpFlags {
4231            schema_only: false,
4232            answers: Some(manifest_path.to_path_buf()),
4233        };
4234        apply_with_lookups(
4235            store,
4236            &flags,
4237            ApplyOptions {
4238                mode,
4239                ..ApplyOptions::default()
4240            },
4241            lookup,
4242            prompter,
4243        )
4244    }
4245
4246    use crate::cli::tests_common::dev_store_read;
4247
4248    const SECRET_PATH: &str = "legal/_/messaging-telegram/telegram_bot_token";
4249
4250    fn secrets_manifest(var: &str) -> Value {
4251        json!({
4252            "schema": ENV_MANIFEST_SCHEMA_V1,
4253            "environment": {"id": "local"},
4254            "secrets": [{"path": SECRET_PATH, "from_env": var}]
4255        })
4256    }
4257
4258    #[test]
4259    fn secrets_e2e_put_writes_value_and_redacts() {
4260        let (dir, store) = seeded_store_with_dev_secrets();
4261        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_LEGAL_BOT_TOKEN"));
4262        let lookup =
4263            |name: &str| (name == "APPLY_LEGAL_BOT_TOKEN").then(|| "tok-secret-9000".to_string());
4264
4265        let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup)
4266            .expect("apply succeeds");
4267
4268        // The report names the env VAR and says `put` — never the value.
4269        let envelope = serde_json::to_string(&outcome).unwrap();
4270        assert!(
4271            !envelope.contains("tok-secret-9000"),
4272            "envelope must not leak the value: {envelope}"
4273        );
4274        assert!(envelope.contains("APPLY_LEGAL_BOT_TOKEN"), "{envelope}");
4275        let actions = step_actions(&outcome.result);
4276        assert!(
4277            actions.contains(&("put-secret".to_string(), "put".to_string())),
4278            "{actions:?}"
4279        );
4280
4281        // Written through to the dev store the runtime reader resolves.
4282        let store_path = dir
4283            .path()
4284            .join("local")
4285            .join(super::super::secrets::DEV_STORE_RELATIVE);
4286        let bytes = dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"));
4287        assert_eq!(bytes, b"tok-secret-9000".to_vec());
4288
4289        // The audit event carries a fresh (not deterministic) key and no value.
4290        let audit_path = dir.path().join("local/audit/events.jsonl");
4291        let put_ikeys = || -> Vec<String> {
4292            std::fs::read_to_string(&audit_path)
4293                .unwrap()
4294                .lines()
4295                .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
4296                .filter(|e| e["noun"] == "secrets" && e["verb"] == "put")
4297                .map(|e| e["idempotency_key"].as_str().expect("ikey").to_string())
4298                .collect()
4299        };
4300        let audit = std::fs::read_to_string(&audit_path).unwrap();
4301        assert!(
4302            !audit.contains("tok-secret-9000"),
4303            "audit log must not leak the value"
4304        );
4305        assert_eq!(put_ikeys().len(), 1, "one put audit event: {audit}");
4306
4307        // Always-put: a re-apply is NOT a no-op for the secret (cannot diff).
4308        let second =
4309            run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).expect("re-apply");
4310        assert_eq!(second.result["changed"], 1, "{}", second.result);
4311
4312        // Two invocations must mint DIFFERENT idempotency keys (fresh per
4313        // invocation — the contract after removing deterministic keys from
4314        // put-secret steps).
4315        let ikeys = put_ikeys();
4316        assert_eq!(ikeys.len(), 2, "two put events after re-apply: {ikeys:?}");
4317        assert_ne!(
4318            ikeys[0], ikeys[1],
4319            "two invocations must mint different keys"
4320        );
4321    }
4322
4323    #[test]
4324    fn missing_or_empty_secret_env_var_fails_apply_before_mutation() {
4325        let (dir, store) = seeded_store_with_dev_secrets();
4326        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_MISSING_VAR"));
4327        for value in [None, Some(String::new())] {
4328            let lookup = |_: &str| value.clone();
4329            let err =
4330                run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4331            match err {
4332                OpError::InvalidArgument(msg) => {
4333                    assert!(msg.contains("1 missing input(s)"), "got: {msg}");
4334                    assert!(msg.contains("APPLY_MISSING_VAR"), "got: {msg}");
4335                    assert!(msg.contains(SECRET_PATH), "got: {msg}");
4336                }
4337                other => panic!("expected InvalidArgument, got {other:?}"),
4338            }
4339        }
4340        // The missing gate fires before execute: nothing written.
4341        assert!(
4342            !dir.path()
4343                .join("local")
4344                .join(super::super::secrets::DEV_STORE_RELATIVE)
4345                .exists()
4346        );
4347        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4348    }
4349
4350    #[test]
4351    fn non_dev_store_backend_fails_at_validation() {
4352        let (dir, store) = seeded_store();
4353        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_VAR"));
4354        let lookup = |_: &str| Some("v".to_string());
4355
4356        // Existing env with NO secrets pack bound → the shared precondition
4357        // fires at validation time.
4358        let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4359        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4360
4361        // Non-dev-store backend → not-yet-implemented at validation time.
4362        let mut env = load_local(&store);
4363        env.packs.push(make_binding(
4364            CapabilitySlot::Secrets,
4365            "greentic.secrets.aws-sm@1.0.0",
4366        ));
4367        store.save(&env).unwrap();
4368        let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4369        assert!(matches!(err, OpError::NotYetImplemented(_)), "got {err:?}");
4370
4371        // Both failures pre-date execution: no audit events were appended.
4372        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4373    }
4374
4375    #[test]
4376    fn secrets_plan_orders_before_bundles_and_dry_run_writes_nothing() {
4377        let (dir, store) = seeded_store_with_dev_secrets();
4378        let mut manifest = full_manifest(&fixture());
4379        manifest["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_ORDER_TOKEN"}]);
4380        let manifest_path = write_manifest(dir.path(), &manifest);
4381        let lookup = |_: &str| Some("tok-order".to_string());
4382
4383        let plan =
4384            run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup).expect("dry-run");
4385        let steps = plan.result["steps"].as_array().expect("steps");
4386        let pos = |kind: &str| {
4387            steps
4388                .iter()
4389                .position(|s| s["kind"] == kind)
4390                .unwrap_or_else(|| panic!("no `{kind}` step in {steps:?}"))
4391        };
4392        assert!(pos("ensure-environment") < pos("put-secret"));
4393        assert!(
4394            pos("put-secret") < pos("deploy-bundle"),
4395            "secrets must land before bundles so a fresh revision never \
4396             resolves a missing secret"
4397        );
4398
4399        // Dry-run resolved the value but wrote nothing.
4400        assert!(
4401            !dir.path()
4402                .join("local")
4403                .join(super::super::secrets::DEV_STORE_RELATIVE)
4404                .exists(),
4405            "dry-run must not write the dev store"
4406        );
4407    }
4408
4409    // `SecretValue`'s redacting-Debug property is pinned by its owning
4410    // module's test (`runtime_secrets::tests::secret_value_debug_is_redacted`).
4411
4412    #[test]
4413    fn check_excludes_undiffable_secret_puts_but_counts_real_drift() {
4414        let (dir, store) = seeded_store_with_dev_secrets();
4415        let lookup =
4416            |name: &str| (name == "APPLY_LEGAL_BOT_TOKEN").then(|| "tok-secret-9000".to_string());
4417
4418        // Converged env + a manifest whose only non-no-op row is the
4419        // always-put secret: excluded from the verdict — check passes,
4420        // reports the row as undiffable, and writes nothing.
4421        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_LEGAL_BOT_TOKEN"));
4422        let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Check, &lookup)
4423            .expect("check passes");
4424        assert_eq!(outcome.result["mode"], "check");
4425        assert_eq!(outcome.result["undiffable"], 1, "{}", outcome.result);
4426        assert_eq!(
4427            outcome.result["changed"], 1,
4428            "the put row stays visible in the report: {}",
4429            outcome.result
4430        );
4431        assert!(
4432            !dir.path()
4433                .join("local")
4434                .join(super::super::secrets::DEV_STORE_RELATIVE)
4435                .exists(),
4436            "--check must not write the dev store"
4437        );
4438
4439        // Mixed manifest: a pending bundle deploy is real drift — check
4440        // fails, and the count excludes the undiffable put row.
4441        let mut mixed = full_manifest(&fixture());
4442        mixed["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_LEGAL_BOT_TOKEN"}]);
4443        let mixed_path = write_manifest(dir.path(), &mixed);
4444        let err = run_with_lookup(&store, &mixed_path, ApplyMode::Check, &lookup).unwrap_err();
4445        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4446        assert!(
4447            err.to_string().contains("4 pending change(s)"),
4448            "put row must not count toward drift: {err}"
4449        );
4450    }
4451
4452    // --- Adversarial-review fix 1: pre-mutation artifact re-hash gate ---
4453
4454    #[test]
4455    fn tampered_artifact_aborts_before_any_secret_write() {
4456        // Between validation (digests computed) and execute, the
4457        // confirmation pause is unbounded. If an artifact changes during
4458        // the pause, the pre-mutation gate at the top of execute() must
4459        // abort the whole apply before ANY step (including put-secret)
4460        // mutates the store.
4461        let (dir, store) = seeded_store_with_dev_secrets();
4462
4463        // Copy the real bundle to a temp file we can tamper.
4464        let tamper_bundle = dir.path().join("tamper.gtbundle");
4465        std::fs::copy(fixture(), &tamper_bundle).unwrap();
4466
4467        let manifest = json!({
4468            "schema": ENV_MANIFEST_SCHEMA_V1,
4469            "environment": {"id": "local"},
4470            "bundles": [{"bundle_id": "quickstart", "bundle_path": &tamper_bundle}],
4471            "secrets": [{"path": SECRET_PATH, "from_env": "APPLY_TAMPER_TOKEN"}]
4472        });
4473        let manifest_path = write_manifest(dir.path(), &manifest);
4474        let lookup = |name: &str| (name == "APPLY_TAMPER_TOKEN").then(|| "tok-tamper".to_string());
4475
4476        // Run the validation + diff pipeline directly (mirroring apply's
4477        // flow), then tamper the artifact, then call execute.
4478        let loaded: EnvManifest = super::super::load_answers(&manifest_path).unwrap();
4479        loaded.validate_shape().unwrap();
4480        let manifest_dir = manifest_path.parent().unwrap().to_path_buf();
4481        let ctx = resolve_and_validate(
4482            &store,
4483            loaded,
4484            &manifest_dir,
4485            "test".to_string(),
4486            &lookup,
4487            None,
4488            &BTreeMap::new(),
4489        )
4490        .unwrap();
4491        let steps = diff(&store, &ctx).unwrap();
4492
4493        // Tamper the bundle AFTER diff (simulating a change during the
4494        // confirmation pause).
4495        std::fs::write(&tamper_bundle, b"tampered bytes").unwrap();
4496
4497        let err = execute(&store, &ctx, &steps).expect_err("tampered artifact must abort");
4498        assert!(
4499            matches!(&err, OpError::Conflict(msg) if msg.contains("changed since the plan")),
4500            "expected Conflict about artifact change, got: {err:?}"
4501        );
4502
4503        // The dev-store file must NOT exist — no secret was written.
4504        assert!(
4505            !dir.path()
4506                .join("local")
4507                .join(super::super::secrets::DEV_STORE_RELATIVE)
4508                .exists(),
4509            "tampered artifact must abort before any secret write"
4510        );
4511    }
4512
4513    // --- operator-surface PR-1: missing-inputs contract -------------------------
4514
4515    #[test]
4516    fn missing_inputs_accumulate_across_secrets_and_bundles() {
4517        let (dir, store) = seeded_store_with_dev_secrets();
4518        let absent = dir.path().join("ghost.gtbundle");
4519        let manifest = json!({
4520            "schema": ENV_MANIFEST_SCHEMA_V1,
4521            "environment": {"id": "local"},
4522            "secrets": [
4523                {"path": "legal/_/messaging-telegram/telegram_bot_token",
4524                 "from_env": "APPLY_VAR_A"},
4525                {"path": "accounting/_/messaging-telegram/telegram_bot_token",
4526                 "from_env": "APPLY_VAR_B"}
4527            ],
4528            "bundles": [{"bundle_id": "ghost", "bundle_path": absent}]
4529        });
4530        let manifest_path = write_manifest(dir.path(), &manifest);
4531        let lookup = |_: &str| None;
4532
4533        // Dry-run: ALL THREE gaps in one report, stable order (secrets in
4534        // manifest order, then bundles), exit 0.
4535        let plan = run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup)
4536            .expect("dry-run never fails on missing inputs");
4537        let rows: Vec<(String, String, String)> = plan.result["missing"]
4538            .as_array()
4539            .expect("missing array")
4540            .iter()
4541            .map(|m| {
4542                (
4543                    m["kind"].as_str().unwrap().to_string(),
4544                    m["key"].as_str().unwrap().to_string(),
4545                    m["source"].as_str().unwrap().to_string(),
4546                )
4547            })
4548            .collect();
4549        assert_eq!(rows.len(), 3, "{rows:?}");
4550        assert_eq!(
4551            (rows[0].0.as_str(), rows[0].2.as_str()),
4552            ("secret_value", "env:APPLY_VAR_A")
4553        );
4554        assert_eq!(rows[0].1, "legal/_/messaging-telegram/telegram_bot_token");
4555        assert_eq!(
4556            (rows[1].0.as_str(), rows[1].2.as_str()),
4557            ("secret_value", "env:APPLY_VAR_B")
4558        );
4559        assert_eq!(rows[2].0, "bundle_artifact");
4560        assert_eq!(rows[2].1, "ghost");
4561        assert!(rows[2].2.starts_with("path:"), "{rows:?}");
4562
4563        // Apply: the gate names all three gaps in ONE error, pre-mutation.
4564        let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &lookup).unwrap_err();
4565        let msg = err.to_string();
4566        assert!(msg.contains("3 missing input(s)"), "{msg}");
4567        assert!(msg.contains("APPLY_VAR_A"), "{msg}");
4568        assert!(msg.contains("APPLY_VAR_B"), "{msg}");
4569        assert!(msg.contains("ghost"), "{msg}");
4570        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4571    }
4572
4573    #[test]
4574    fn check_reports_missing_but_excludes_it_from_the_verdict() {
4575        let (dir, store) = seeded_store_with_dev_secrets();
4576        // Converged env + one unset var: check exits 0 — a CI convergence
4577        // gate must be runnable without holding credentials — and reports
4578        // the gap.
4579        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_UNSET_CI_VAR"));
4580        let lookup = |_: &str| None;
4581        let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Check, &lookup)
4582            .expect("check passes without the secret value");
4583        assert_eq!(outcome.result["mode"], "check");
4584        assert_eq!(
4585            outcome.result["missing"].as_array().unwrap().len(),
4586            1,
4587            "{}",
4588            outcome.result
4589        );
4590        assert_eq!(outcome.result["undiffable"], 1);
4591
4592        // Real drift still fails check, with or without the values.
4593        let mut mixed = full_manifest(&fixture());
4594        mixed["secrets"] = json!([{"path": SECRET_PATH, "from_env": "APPLY_UNSET_CI_VAR"}]);
4595        let mixed_path = write_manifest(dir.path(), &mixed);
4596        let err = run_with_lookup(&store, &mixed_path, ApplyMode::Check, &lookup).unwrap_err();
4597        assert!(matches!(err, OpError::Conflict(_)), "got {err:?}");
4598    }
4599
4600    #[test]
4601    fn prompter_fills_missing_secret_and_plan_says_prompted() {
4602        let (dir, store) = seeded_store_with_dev_secrets();
4603        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_PROMPT_VAR"));
4604        let lookup = |_: &str| None;
4605        let prompter = |path: &str, from_env: &str| {
4606            assert_eq!(path, SECRET_PATH);
4607            assert_eq!(from_env, "APPLY_PROMPT_VAR");
4608            Some("tok-prompted-1".to_string())
4609        };
4610
4611        let outcome = run_with_lookup_and_prompter(
4612            &store,
4613            &manifest_path,
4614            ApplyMode::Apply,
4615            &lookup,
4616            Some(&prompter),
4617        )
4618        .expect("prompted apply succeeds");
4619
4620        // The plan row says `prompted` (not `from $VAR`), the report's
4621        // missing list is empty, and the value never leaks anywhere.
4622        let envelope = serde_json::to_string(&outcome).unwrap();
4623        assert!(!envelope.contains("tok-prompted-1"), "{envelope}");
4624        let steps = outcome.result["steps"].as_array().unwrap();
4625        let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4626        assert_eq!(put["detail"], "prompted (cannot diff until A9)");
4627        assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4628
4629        // The prompted value reached the dev store the runtime reads.
4630        let store_path = dir
4631            .path()
4632            .join("local")
4633            .join(super::super::secrets::DEV_STORE_RELATIVE);
4634        let bytes = dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"));
4635        assert_eq!(bytes, b"tok-prompted-1".to_vec());
4636
4637        // Audit must not leak it either.
4638        let audit = std::fs::read_to_string(dir.path().join("local/audit/events.jsonl")).unwrap();
4639        assert!(!audit.contains("tok-prompted-1"));
4640    }
4641
4642    #[test]
4643    fn prompter_decline_leaves_input_missing() {
4644        let (dir, store) = seeded_store_with_dev_secrets();
4645        let manifest_path = write_manifest(dir.path(), &secrets_manifest("APPLY_DECLINE_VAR"));
4646        let lookup = |_: &str| None;
4647        let prompter = |_: &str, _: &str| None;
4648        let err = run_with_lookup_and_prompter(
4649            &store,
4650            &manifest_path,
4651            ApplyMode::Apply,
4652            &lookup,
4653            Some(&prompter),
4654        )
4655        .unwrap_err();
4656        assert!(err.to_string().contains("1 missing input(s)"), "{err}");
4657        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4658    }
4659
4660    // --- paste-sourced secrets (manifest `from_env` absent) -----------------
4661
4662    /// A paste-sourced secret entry: no `from_env`, value supplied out of band.
4663    fn paste_secrets_manifest() -> Value {
4664        json!({
4665            "schema": ENV_MANIFEST_SCHEMA_V1,
4666            "environment": {"id": "local"},
4667            "secrets": [{"path": SECRET_PATH}]
4668        })
4669    }
4670
4671    fn dev_store_value(dir: &Path) -> Vec<u8> {
4672        let store_path = dir
4673            .join("local")
4674            .join(super::super::secrets::DEV_STORE_RELATIVE);
4675        dev_store_read(&store_path, &format!("secrets://local/{SECRET_PATH}"))
4676    }
4677
4678    #[test]
4679    fn paste_secret_prefilled_value_is_put_and_plan_says_pasted() {
4680        // The wizard's hand-off: a pre-collected value, no env var, no prompt.
4681        let (dir, store) = seeded_store_with_dev_secrets();
4682        let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4683        let flags = OpFlags {
4684            schema_only: false,
4685            answers: Some(manifest_path.clone()),
4686        };
4687        let mut prefilled = BTreeMap::new();
4688        prefilled.insert(
4689            SECRET_PATH.to_string(),
4690            SecretValue::from("tok-pasted-42".to_string()),
4691        );
4692        let outcome = apply_with_lookups(
4693            &store,
4694            &flags,
4695            ApplyOptions {
4696                mode: ApplyMode::Apply,
4697                non_interactive: true,
4698                prefilled_secrets: prefilled,
4699                ..ApplyOptions::default()
4700            },
4701            &|_| None,
4702            None,
4703        )
4704        .expect("prefilled paste apply succeeds");
4705
4706        let envelope = serde_json::to_string(&outcome).unwrap();
4707        assert!(!envelope.contains("tok-pasted-42"), "{envelope}");
4708        let steps = outcome.result["steps"].as_array().unwrap();
4709        let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4710        assert_eq!(put["detail"], "pasted (cannot diff until A9)");
4711        assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4712        assert_eq!(dev_store_value(dir.path()), b"tok-pasted-42".to_vec());
4713    }
4714
4715    #[test]
4716    fn paste_secret_already_in_store_is_a_noop() {
4717        // Store-as-source-of-truth: with the value already present, a re-apply
4718        // that can't re-collect it (no prefill, no prompter) is satisfied —
4719        // no put step, no missing input, value untouched.
4720        let (dir, store) = seeded_store_with_dev_secrets();
4721        let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4722        // Seed via a first prefilled apply.
4723        let mut prefilled = BTreeMap::new();
4724        prefilled.insert(
4725            SECRET_PATH.to_string(),
4726            SecretValue::from("tok-stored".to_string()),
4727        );
4728        let flags = OpFlags {
4729            schema_only: false,
4730            answers: Some(manifest_path.clone()),
4731        };
4732        apply_with_lookups(
4733            &store,
4734            &flags,
4735            ApplyOptions {
4736                mode: ApplyMode::Apply,
4737                non_interactive: true,
4738                prefilled_secrets: prefilled,
4739                ..ApplyOptions::default()
4740            },
4741            &|_| None,
4742            None,
4743        )
4744        .expect("seed apply");
4745
4746        // Second apply: no prefill, no prompter, non-interactive.
4747        let outcome = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &|_| None)
4748            .expect("re-apply is a no-op, not a missing-input failure");
4749        let steps = outcome.result["steps"].as_array().unwrap();
4750        assert!(
4751            !steps.iter().any(|s| s["kind"] == "put-secret"),
4752            "an already-stored paste secret needs no put step: {steps:?}"
4753        );
4754        assert!(outcome.result["missing"].as_array().unwrap().is_empty());
4755        assert_eq!(dev_store_value(dir.path()), b"tok-stored".to_vec());
4756    }
4757
4758    #[test]
4759    fn paste_secret_missing_when_absent_and_non_interactive() {
4760        // No prefill, not in the store, no prompter → reported missing with a
4761        // `paste` source (not an env var), and apply aborts before mutation.
4762        let (dir, store) = seeded_store_with_dev_secrets();
4763        let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4764        let err = run_with_lookup(&store, &manifest_path, ApplyMode::Apply, &|_| None).unwrap_err();
4765        let msg = err.to_string();
4766        assert!(msg.contains("1 missing input(s)"), "{msg}");
4767        assert!(msg.contains("paste"), "names the paste source: {msg}");
4768        assert!(msg.contains(SECRET_PATH), "{msg}");
4769        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4770    }
4771
4772    #[test]
4773    fn paste_secret_prompts_with_value_wording() {
4774        // Interactive, no prefill, not in store → masked prompt; the prompter
4775        // receives an empty `from_env` (paste wording) and the value is put.
4776        let (dir, store) = seeded_store_with_dev_secrets();
4777        let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4778        let prompter = |path: &str, from_env: &str| {
4779            assert_eq!(path, SECRET_PATH);
4780            assert_eq!(from_env, "", "paste secrets prompt with an empty from_env");
4781            Some("tok-typed".to_string())
4782        };
4783        let outcome = run_with_lookup_and_prompter(
4784            &store,
4785            &manifest_path,
4786            ApplyMode::Apply,
4787            &|_| None,
4788            Some(&prompter),
4789        )
4790        .expect("paste prompt apply succeeds");
4791        let steps = outcome.result["steps"].as_array().unwrap();
4792        let put = steps.iter().find(|s| s["kind"] == "put-secret").unwrap();
4793        assert_eq!(put["detail"], "pasted (cannot diff until A9)");
4794        assert_eq!(dev_store_value(dir.path()), b"tok-typed".to_vec());
4795    }
4796
4797    #[test]
4798    fn paste_secret_empty_prefilled_is_treated_as_missing() {
4799        // A prefilled value that is empty must NOT slip through as resolved
4800        // (it would only be rejected by `secrets::put` at execute, after
4801        // earlier steps mutated state). Like the env/prompt branches, an empty
4802        // prefilled value falls through to the missing-input report.
4803        let (dir, store) = seeded_store_with_dev_secrets();
4804        let manifest_path = write_manifest(dir.path(), &paste_secrets_manifest());
4805        let flags = OpFlags {
4806            schema_only: false,
4807            answers: Some(manifest_path.clone()),
4808        };
4809        let mut prefilled = BTreeMap::new();
4810        prefilled.insert(SECRET_PATH.to_string(), SecretValue::from(String::new()));
4811        let err = apply_with_lookups(
4812            &store,
4813            &flags,
4814            ApplyOptions {
4815                mode: ApplyMode::Apply,
4816                non_interactive: true,
4817                prefilled_secrets: prefilled,
4818                ..ApplyOptions::default()
4819            },
4820            &|_| None,
4821            None,
4822        )
4823        .unwrap_err();
4824        let msg = err.to_string();
4825        assert!(msg.contains("1 missing input(s)"), "{msg}");
4826        assert!(msg.contains("paste"), "names the paste source: {msg}");
4827        // Aborted before any mutation — no audit log written.
4828        assert!(!dir.path().join("local/audit/events.jsonl").exists());
4829    }
4830
4831    #[test]
4832    fn emit_answers_template_writes_valid_manifest_and_touches_nothing() {
4833        let dir = tempdir().unwrap();
4834        let store = LocalFsStore::new(dir.path());
4835        let out = dir.path().join("template.env.json");
4836        let outcome = emit_answers_template(&out).expect("template emit succeeds");
4837        assert_eq!(outcome.result["mode"], "emit-answers-template");
4838
4839        // What lands on disk parses under deny_unknown_fields and is
4840        // shape-valid as-is (the env_manifest guard test pins the source;
4841        // this pins the written artifact).
4842        let written: EnvManifest = serde_json::from_slice(&std::fs::read(&out).unwrap())
4843            .expect("written template parses as EnvManifest");
4844        written
4845            .validate_shape()
4846            .expect("written template is shape-valid");
4847        // No env/store state was created.
4848        assert!(!store.exists(&EnvId::try_from("local").unwrap()).unwrap());
4849    }
4850
4851    // --- Fix 8: welcome-flow reachability lives in env-side validation only ---
4852
4853    #[test]
4854    fn welcome_flow_not_in_links_rejected_by_env_validation() {
4855        // Manifest declares a welcome_flow whose bundle_id IS in bundles[] but
4856        // NOT in that endpoint's links[]. Shape validation passes (the check
4857        // was removed from validate_shape); env-side validation catches it.
4858        let (dir, store) = seeded_store();
4859        let manifest = json!({
4860            "schema": ENV_MANIFEST_SCHEMA_V1,
4861            "environment": {"id": "local"},
4862            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}],
4863            "messaging_endpoints": [{
4864                "name": "n",
4865                "provider_type": "messaging.telegram.bot",
4866                "links": [],
4867                "welcome_flow": {"bundle_id": "quickstart", "pack_id": "p", "flow_id": "f"}
4868            }]
4869        });
4870        let manifest_path = write_manifest(dir.path(), &manifest);
4871        let err = run_apply(&store, &manifest_path).unwrap_err();
4872        match err {
4873            OpError::InvalidArgument(msg) => {
4874                assert!(msg.contains("links[]"), "got: {msg}");
4875            }
4876            other => panic!("expected InvalidArgument mentioning links[], got {other:?}"),
4877        }
4878        // No mutation: env-side validation fires before any step.
4879        let env = load_local(&store);
4880        assert!(env.messaging_endpoints.is_empty());
4881        assert!(env.revisions.is_empty());
4882    }
4883
4884    // --- Fix 10a: hash_json determinism pin ---
4885
4886    #[test]
4887    fn hash_json_output_is_stable() {
4888        // Pins idempotency-key stability across builds. serde_json without
4889        // preserve_order sorts keys, so {"b":1,"a":[true,"x"]} serializes
4890        // as {"a":[true,"x"],"b":1}.
4891        assert_eq!(
4892            hash_json(&json!({"b": 1, "a": [true, "x"]})),
4893            "f15ef113d6e0c876b9ea9e90ebc36ad3f8b350d44634ba2fc407e978fb8cebeb"
4894        );
4895    }
4896
4897    // --- Fix 10c: deploy rejects differing route binding (contract pin) ---
4898
4899    #[test]
4900    fn deploy_rejects_differing_binding_contract_pin() {
4901        // Apply relies on deploy-first / route_binding: None ordering for the
4902        // re-deploy site. That ordering only works because deploy rejects a
4903        // differing binding on an existing deployment (returning Conflict).
4904        // This test pins that contract so a future change to deploy's binding
4905        // check breaks the apply tests explicitly.
4906        let (_dir, store) = seeded_store();
4907        let mut p = super::super::deploy::BundleDeployPayload {
4908            environment_id: "local".to_string(),
4909            bundle_id: "quickstart".to_string(),
4910            customer_id: None,
4911            bundle_path: Some(fixture()),
4912            bundle_source_uri: None,
4913            remote_pins: None,
4914            idempotency_key: None,
4915            config_overrides: None,
4916            route_binding: Some(super::super::bundles::RouteBindingPayload {
4917                hosts: Vec::new(),
4918                path_prefixes: vec!["/v1".to_string()],
4919                tenant_selector: None,
4920            }),
4921            revenue_share: None,
4922        };
4923        super::super::deploy::deploy(&store, &OpFlags::default(), Some(p.clone()))
4924            .expect("first deploy");
4925        // Re-deploy with a DIFFERENT binding.
4926        p.route_binding = Some(super::super::bundles::RouteBindingPayload {
4927            hosts: Vec::new(),
4928            path_prefixes: vec!["/v2".to_string()],
4929            tenant_selector: None,
4930        });
4931        let err = super::super::deploy::deploy(&store, &OpFlags::default(), Some(p)).unwrap_err();
4932        match err {
4933            OpError::Conflict(msg) => {
4934                assert!(
4935                    msg.contains("route_binding differs"),
4936                    "expected Conflict about differing binding, got: {msg}"
4937                );
4938            }
4939            other => panic!("expected Conflict, got {other:?}"),
4940        }
4941    }
4942
4943    // --- G5: env-pack bindings ---
4944
4945    #[test]
4946    fn pack_binding_add_then_idempotent() {
4947        let (dir, store) = seeded_store();
4948        let manifest = json!({
4949            "schema": ENV_MANIFEST_SCHEMA_V1,
4950            "environment": {"id": "local"},
4951            "packs": [{
4952                "slot": "deployer",
4953                "kind": "greentic.deployer.local@1.0.0",
4954                "pack_ref": "builtin:deployer-local"
4955            }]
4956        });
4957        let manifest_path = write_manifest(dir.path(), &manifest);
4958        let outcome = run_apply(&store, &manifest_path).expect("add pack binding");
4959        let actions = step_actions(&outcome.result);
4960        assert!(
4961            actions.contains(&("add-pack-binding".to_string(), "create".to_string())),
4962            "must plan add-pack-binding: {actions:?}"
4963        );
4964
4965        let env = load_local(&store);
4966        let binding = env.pack_for_slot(CapabilitySlot::Deployer);
4967        assert!(binding.is_some(), "deployer slot must be bound");
4968        let b = binding.unwrap();
4969        assert_eq!(b.kind.to_string(), "greentic.deployer.local@1.0.0");
4970        assert_eq!(b.pack_ref.as_str(), "builtin:deployer-local");
4971
4972        // Idempotent re-apply.
4973        let second = run_apply(&store, &manifest_path).expect("re-apply");
4974        assert_eq!(second.result["changed"], 0, "{}", second.result);
4975    }
4976
4977    #[test]
4978    fn pack_binding_update_on_kind_change() {
4979        let (dir, store) = seeded_store();
4980        // Seed with v1.
4981        let v1 = json!({
4982            "schema": ENV_MANIFEST_SCHEMA_V1,
4983            "environment": {"id": "local"},
4984            "packs": [{
4985                "slot": "deployer",
4986                "kind": "greentic.deployer.local@1.0.0",
4987                "pack_ref": "builtin:deployer-local"
4988            }]
4989        });
4990        let v1_path = write_manifest(dir.path(), &v1);
4991        run_apply(&store, &v1_path).expect("first apply");
4992
4993        // Update to v2.
4994        let v2 = json!({
4995            "schema": ENV_MANIFEST_SCHEMA_V1,
4996            "environment": {"id": "local"},
4997            "packs": [{
4998                "slot": "deployer",
4999                "kind": "greentic.deployer.local@2.0.0",
5000                "pack_ref": "builtin:deployer-local-v2"
5001            }]
5002        });
5003        let v2_path = write_manifest(dir.path(), &v2);
5004        let plan = run_dry(&store, &v2_path).expect("dry-run");
5005        let actions = step_actions(&plan.result);
5006        assert!(
5007            actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5008            "must plan update-pack-binding: {actions:?}"
5009        );
5010
5011        run_apply(&store, &v2_path).expect("apply update");
5012        let env = load_local(&store);
5013        let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5014        assert_eq!(b.kind.to_string(), "greentic.deployer.local@2.0.0");
5015        assert_eq!(b.pack_ref.as_str(), "builtin:deployer-local-v2");
5016        assert!(b.generation > 0, "generation must bump on update");
5017    }
5018
5019    #[test]
5020    fn apply_stages_pack_answers_into_env_store() {
5021        let (dir, store) = seeded_store();
5022        // The answers file lives next to the manifest (manifest-relative ref).
5023        let answers = json!({"runtime_image": "ghcr.io/x/y:dev", "tunnel": "cloudflared"});
5024        std::fs::write(
5025            dir.path().join("deployer-answers.json"),
5026            serde_json::to_vec_pretty(&answers).unwrap(),
5027        )
5028        .unwrap();
5029        let manifest = json!({
5030            "schema": ENV_MANIFEST_SCHEMA_V1,
5031            "environment": {"id": "local"},
5032            "packs": [{
5033                "slot": "deployer",
5034                "kind": "greentic.deployer.local@1.0.0",
5035                "pack_ref": "builtin:deployer-local",
5036                "answers_ref": "deployer-answers.json"
5037            }]
5038        });
5039        let manifest_path = write_manifest(dir.path(), &manifest);
5040        run_apply(&store, &manifest_path).expect("apply with answers_ref");
5041
5042        // The file is staged into the env store at the canonical path...
5043        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
5044        let staged = env_dir.join("env-packs/deployer/answers.json");
5045        assert!(staged.is_file(), "answers must be staged at {staged:?}");
5046        let staged_val: Value = serde_json::from_slice(&std::fs::read(&staged).unwrap()).unwrap();
5047        assert_eq!(staged_val, answers);
5048
5049        // ...and the binding records the env-relative staged ref, so reconcile
5050        // (which resolves against the env dir) finds it.
5051        let env = load_local(&store);
5052        let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5053        assert_eq!(
5054            b.answers_ref.as_deref(),
5055            Some(Path::new("env-packs/deployer/answers.json"))
5056        );
5057
5058        // Idempotent: re-apply is a no-op (content unchanged).
5059        let second = run_apply(&store, &manifest_path).expect("re-apply");
5060        assert_eq!(second.result["changed"], 0, "{}", second.result);
5061    }
5062
5063    #[test]
5064    fn apply_restages_pack_answers_on_content_change() {
5065        let (dir, store) = seeded_store();
5066        let p = dir.path().join("deployer-answers.json");
5067        std::fs::write(&p, br#"{"runtime_image":"img:v1"}"#).unwrap();
5068        let manifest = json!({
5069            "schema": ENV_MANIFEST_SCHEMA_V1,
5070            "environment": {"id": "local"},
5071            "packs": [{
5072                "slot": "deployer",
5073                "kind": "greentic.deployer.local@1.0.0",
5074                "pack_ref": "builtin:deployer-local",
5075                "answers_ref": "deployer-answers.json"
5076            }]
5077        });
5078        let manifest_path = write_manifest(dir.path(), &manifest);
5079        run_apply(&store, &manifest_path).expect("first apply");
5080
5081        // Edit the source content (same filename) and re-apply — the staged
5082        // copy must refresh even though the manifest ref string is unchanged.
5083        std::fs::write(&p, br#"{"runtime_image":"img:v2"}"#).unwrap();
5084        let plan = run_dry(&store, &manifest_path).expect("dry-run after edit");
5085        let actions = step_actions(&plan.result);
5086        assert!(
5087            actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5088            "content change must plan update-pack-binding: {actions:?}"
5089        );
5090        run_apply(&store, &manifest_path).expect("re-apply");
5091        let env_dir = store.env_dir(&EnvId::try_from("local").unwrap()).unwrap();
5092        let staged = std::fs::read(env_dir.join("env-packs/deployer/answers.json")).unwrap();
5093        assert_eq!(staged, br#"{"runtime_image":"img:v2"}"#);
5094    }
5095
5096    #[test]
5097    fn apply_with_oci_bundle_and_linking_endpoint() {
5098        let (dir, store) = seeded_store();
5099        let manifest = json!({
5100            "schema": ENV_MANIFEST_SCHEMA_V1,
5101            "environment": {"id": "local"},
5102            "bundles": [{
5103                "bundle_id": "webchat-bot",
5104                "bundle_path": fixture(),
5105                "bundle_source_uri": "oci://ghcr.io/greenticai/demo/webchat-bot:v1",
5106                "route_binding": {
5107                    "path_prefixes": ["/"],
5108                    "tenant_selector": {"tenant": "tenant-default", "team": "default"}
5109                }
5110            }],
5111            "messaging_endpoints": [{
5112                "name": "webchat-bot",
5113                "provider_type": "messaging.telegram.bot",
5114                "links": ["webchat-bot"]
5115            }]
5116        });
5117        let manifest_path = write_manifest(dir.path(), &manifest);
5118        // The endpoint link resolves because the bundle is declared in the
5119        // manifest, and the OCI source is recorded on the staged revision so a
5120        // K8s worker can pull it at boot — both in a single apply.
5121        run_apply(&store, &manifest_path).expect("apply oci bundle + linking endpoint");
5122        let env = load_local(&store);
5123        assert_eq!(
5124            env.revisions[0].bundle_source_uri.as_deref(),
5125            Some("oci://ghcr.io/greenticai/demo/webchat-bot:v1"),
5126            "manifest bundle_source_uri must reach the staged revision"
5127        );
5128    }
5129
5130    #[test]
5131    fn apply_multi_revision_threads_bundle_source_uri() {
5132        let (dir, store) = seeded_store();
5133        let manifest = json!({
5134            "schema": ENV_MANIFEST_SCHEMA_V1,
5135            "environment": {"id": "local"},
5136            "bundles": [{
5137                "bundle_id": "split-bot",
5138                "revisions": [
5139                    {"name": "a", "bundle_path": fixture(), "weight_bps": 10000,
5140                     "bundle_source_uri": "oci://ghcr.io/greenticai/demo/split:a"}
5141                ]
5142            }]
5143        });
5144        let manifest_path = write_manifest(dir.path(), &manifest);
5145        run_apply(&store, &manifest_path).expect("apply split with oci source");
5146        let env = load_local(&store);
5147        assert_eq!(
5148            env.revisions[0].bundle_source_uri.as_deref(),
5149            Some("oci://ghcr.io/greenticai/demo/split:a"),
5150            "per-revision bundle_source_uri must reach the staged revision"
5151        );
5152    }
5153
5154    #[test]
5155    fn adding_bundle_source_uri_to_converged_deployment_redeploys_and_sets_it() {
5156        let (dir, store) = seeded_store();
5157        // Deploy the bundle with NO OCI source — local-serve only.
5158        let m1 = json!({
5159            "schema": ENV_MANIFEST_SCHEMA_V1,
5160            "environment": {"id": "local"},
5161            "bundles": [{"bundle_id": "b", "bundle_path": fixture()}]
5162        });
5163        let p1 = write_manifest(dir.path(), &m1);
5164        run_apply(&store, &p1).expect("first apply");
5165        assert!(
5166            load_local(&store)
5167                .revisions
5168                .iter()
5169                .all(|r| r.bundle_source_uri.is_none()),
5170            "baseline revision must have no pull ref"
5171        );
5172
5173        // Re-apply the SAME artifact (same digest) but now declaring an OCI
5174        // source. Digest-only convergence would treat this as a no-op; the fix
5175        // must re-deploy so the live revision records the pull ref a K8s worker
5176        // needs to boot.
5177        let m2 = json!({
5178            "schema": ENV_MANIFEST_SCHEMA_V1,
5179            "environment": {"id": "local"},
5180            "bundles": [{"bundle_id": "b", "bundle_path": fixture(),
5181                         "bundle_source_uri": "oci://ex/b:v1"}]
5182        });
5183        let p2 = write_manifest(dir.path(), &m2);
5184        run_apply(&store, &p2).expect("re-apply with oci source");
5185        let env = load_local(&store);
5186        assert!(
5187            env.revisions
5188                .iter()
5189                .any(|r| r.bundle_source_uri.as_deref() == Some("oci://ex/b:v1")),
5190            "adding bundle_source_uri to a same-digest deployment must (re)deploy a \
5191             revision carrying it; saw: {:?}",
5192            env.revisions
5193                .iter()
5194                .map(|r| r.bundle_source_uri.clone())
5195                .collect::<Vec<_>>()
5196        );
5197    }
5198
5199    #[test]
5200    fn answers_ref_drift_on_binding_is_repaired_even_when_staged_file_matches() {
5201        let (dir, store) = seeded_store();
5202        std::fs::write(dir.path().join("deployer-answers.json"), br#"{"x":1}"#).unwrap();
5203        let manifest = json!({
5204            "schema": ENV_MANIFEST_SCHEMA_V1,
5205            "environment": {"id": "local"},
5206            "packs": [{
5207                "slot": "deployer",
5208                "kind": "greentic.deployer.local@1.0.0",
5209                "pack_ref": "builtin:deployer-local",
5210                "answers_ref": "deployer-answers.json"
5211            }]
5212        });
5213        let mp = write_manifest(dir.path(), &manifest);
5214        run_apply(&store, &mp).expect("first apply stages file + canonical ref");
5215
5216        // Simulate an interrupted prior apply: the file is staged (content still
5217        // matches the manifest) but the binding never persisted its
5218        // `answers_ref`. Content-only drift detection would miss this.
5219        let mut env = load_local(&store);
5220        for b in &mut env.packs {
5221            if b.slot == CapabilitySlot::Deployer {
5222                b.answers_ref = None;
5223            }
5224        }
5225        store.save(&env).expect("save drifted env");
5226
5227        // Re-apply: the ref mismatch must replan an update that restores the
5228        // canonical staged ref, even though the staged file content is unchanged.
5229        let plan = run_dry(&store, &mp).expect("dry-run on drifted binding");
5230        let actions = step_actions(&plan.result);
5231        assert!(
5232            actions.contains(&("update-pack-binding".to_string(), "update".to_string())),
5233            "a wrong/missing answers_ref must replan update-pack-binding: {actions:?}"
5234        );
5235        run_apply(&store, &mp).expect("re-apply repairs the ref");
5236        let env = load_local(&store);
5237        let b = env.pack_for_slot(CapabilitySlot::Deployer).expect("bound");
5238        assert_eq!(
5239            b.answers_ref.as_deref(),
5240            Some(Path::new("env-packs/deployer/answers.json")),
5241            "re-apply must restore the canonical staged answers_ref"
5242        );
5243    }
5244
5245    #[test]
5246    fn pack_binding_rejects_messaging_slot() {
5247        let dir = tempdir().unwrap();
5248        let manifest = json!({
5249            "schema": ENV_MANIFEST_SCHEMA_V1,
5250            "environment": {"id": "local"},
5251            "packs": [{
5252                "slot": "messaging",
5253                "kind": "greentic.messaging.telegram@1.0.0",
5254                "pack_ref": "builtin:msg"
5255            }]
5256        });
5257        let manifest_path = write_manifest(dir.path(), &manifest);
5258        let loaded: EnvManifest =
5259            serde_json::from_slice(&std::fs::read(&manifest_path).unwrap()).unwrap();
5260        let err = loaded.validate_shape().unwrap_err();
5261        assert!(err.to_string().contains("messaging"), "got: {err}");
5262    }
5263
5264    // --- G6: extensions ---
5265
5266    #[test]
5267    fn extension_add_then_idempotent() {
5268        let (dir, store) = seeded_store();
5269        let manifest = json!({
5270            "schema": ENV_MANIFEST_SCHEMA_V1,
5271            "environment": {"id": "local"},
5272            "extensions": [{
5273                "kind": "greentic.ext.memory@1.0.0",
5274                "pack_ref": "builtin:memory-chronicle",
5275                "instance_id": "default"
5276            }]
5277        });
5278        let manifest_path = write_manifest(dir.path(), &manifest);
5279        let outcome = run_apply(&store, &manifest_path).expect("add extension");
5280        let actions = step_actions(&outcome.result);
5281        assert!(
5282            actions.contains(&("add-extension".to_string(), "create".to_string())),
5283            "must plan add-extension: {actions:?}"
5284        );
5285
5286        let env = load_local(&store);
5287        assert_eq!(env.extensions.len(), 1);
5288        let ext = &env.extensions[0];
5289        assert_eq!(ext.kind.to_string(), "greentic.ext.memory@1.0.0");
5290        assert_eq!(ext.pack_ref.as_str(), "builtin:memory-chronicle");
5291        assert_eq!(ext.instance_id.as_deref(), Some("default"));
5292
5293        // Idempotent re-apply.
5294        let second = run_apply(&store, &manifest_path).expect("re-apply");
5295        assert_eq!(second.result["changed"], 0, "{}", second.result);
5296    }
5297
5298    #[test]
5299    fn extension_update_on_pack_ref_change() {
5300        let (dir, store) = seeded_store();
5301        let v1 = json!({
5302            "schema": ENV_MANIFEST_SCHEMA_V1,
5303            "environment": {"id": "local"},
5304            "extensions": [{
5305                "kind": "greentic.ext.memory@1.0.0",
5306                "pack_ref": "builtin:memory-v1"
5307            }]
5308        });
5309        let v1_path = write_manifest(dir.path(), &v1);
5310        run_apply(&store, &v1_path).expect("first apply");
5311
5312        let v2 = json!({
5313            "schema": ENV_MANIFEST_SCHEMA_V1,
5314            "environment": {"id": "local"},
5315            "extensions": [{
5316                "kind": "greentic.ext.memory@1.0.0",
5317                "pack_ref": "builtin:memory-v2"
5318            }]
5319        });
5320        let v2_path = write_manifest(dir.path(), &v2);
5321        let plan = run_dry(&store, &v2_path).expect("dry-run");
5322        let actions = step_actions(&plan.result);
5323        assert!(
5324            actions.contains(&("update-extension".to_string(), "update".to_string())),
5325            "must plan update-extension: {actions:?}"
5326        );
5327
5328        run_apply(&store, &v2_path).expect("apply update");
5329        let env = load_local(&store);
5330        assert_eq!(env.extensions[0].pack_ref.as_str(), "builtin:memory-v2");
5331        assert!(
5332            env.extensions[0].generation > 0,
5333            "generation must bump on update"
5334        );
5335    }
5336
5337    #[test]
5338    fn extension_bad_instance_id_rejected_by_shape() {
5339        let manifest = json!({
5340            "schema": ENV_MANIFEST_SCHEMA_V1,
5341            "environment": {"id": "local"},
5342            "extensions": [{
5343                "kind": "greentic.ext.memory@1.0.0",
5344                "pack_ref": "builtin:memory",
5345                "instance_id": "BAD_ID!"
5346            }]
5347        });
5348        let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5349        let err = loaded.validate_shape().unwrap_err();
5350        assert!(err.to_string().contains("instance_id"), "got: {err}");
5351    }
5352
5353    #[test]
5354    fn extension_duplicate_key_rejected_by_shape() {
5355        let manifest = json!({
5356            "schema": ENV_MANIFEST_SCHEMA_V1,
5357            "environment": {"id": "local"},
5358            "extensions": [
5359                {"kind": "greentic.ext.memory@1.0.0", "pack_ref": "a", "instance_id": "x"},
5360                {"kind": "greentic.ext.memory@2.0.0", "pack_ref": "b", "instance_id": "x"},
5361            ]
5362        });
5363        let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5364        let err = loaded.validate_shape().unwrap_err();
5365        assert!(err.to_string().contains("duplicate"), "got: {err}");
5366    }
5367
5368    // --- G7: host-config reconcile ---
5369
5370    #[test]
5371    fn host_config_reconcile_then_idempotent() {
5372        let (dir, store) = seeded_store();
5373        let manifest = json!({
5374            "schema": ENV_MANIFEST_SCHEMA_V1,
5375            "environment": {
5376                "id": "local",
5377                "name": "Local Dev",
5378                "region": "eu-west-1",
5379                "tenant_org_id": "org-99",
5380                "listen_addr": "0.0.0.0:9090"
5381            }
5382        });
5383        let manifest_path = write_manifest(dir.path(), &manifest);
5384        let outcome = run_apply(&store, &manifest_path).expect("host-config apply");
5385        let actions = step_actions(&outcome.result);
5386        assert!(
5387            actions.contains(&("update-host-config".to_string(), "update".to_string()))
5388                || actions.contains(&("update-host-config".to_string(), "create".to_string())),
5389            "must plan update-host-config: {actions:?}"
5390        );
5391
5392        let env = load_local(&store);
5393        assert_eq!(env.name, "Local Dev");
5394        assert_eq!(env.host_config.region.as_deref(), Some("eu-west-1"));
5395        assert_eq!(env.host_config.tenant_org_id.as_deref(), Some("org-99"));
5396        assert_eq!(
5397            env.host_config.listen_addr,
5398            Some("0.0.0.0:9090".parse().unwrap())
5399        );
5400
5401        // Idempotent re-apply.
5402        let second = run_apply(&store, &manifest_path).expect("re-apply");
5403        assert_eq!(second.result["changed"], 0, "{}", second.result);
5404    }
5405
5406    #[test]
5407    fn gui_enabled_reconcile_then_idempotent() {
5408        let (dir, store) = seeded_store();
5409        // `local` resolves GUI-on by default; declaring `false` is an explicit
5410        // override that must persist and survive a re-apply as a no-op.
5411        let manifest = json!({
5412            "schema": ENV_MANIFEST_SCHEMA_V1,
5413            "environment": {
5414                "id": "local",
5415                "gui_enabled": false
5416            }
5417        });
5418        let manifest_path = write_manifest(dir.path(), &manifest);
5419        let outcome = run_apply(&store, &manifest_path).expect("gui apply");
5420        let actions = step_actions(&outcome.result);
5421        assert!(
5422            actions.contains(&("update-host-config".to_string(), "update".to_string())),
5423            "must plan update-host-config for gui_enabled: {actions:?}"
5424        );
5425
5426        let env = load_local(&store);
5427        assert_eq!(env.host_config.gui_enabled, Some(false));
5428        assert!(
5429            !env.host_config.resolved_gui_enabled(),
5430            "explicit false overrides the local default-on"
5431        );
5432
5433        // Idempotent re-apply.
5434        let second = run_apply(&store, &manifest_path).expect("re-apply");
5435        assert_eq!(second.result["changed"], 0, "{}", second.result);
5436    }
5437
5438    #[test]
5439    fn host_config_bad_listen_addr_rejected_by_shape() {
5440        let manifest = json!({
5441            "schema": ENV_MANIFEST_SCHEMA_V1,
5442            "environment": {
5443                "id": "local",
5444                "listen_addr": "not-an-addr"
5445            }
5446        });
5447        let loaded: EnvManifest = serde_json::from_value(manifest).unwrap();
5448        let err = loaded.validate_shape().unwrap_err();
5449        assert!(err.to_string().contains("listen_addr"), "got: {err}");
5450    }
5451
5452    // --- G5+G6 ordering ---
5453
5454    #[test]
5455    fn packs_before_secrets_extensions_before_endpoints() {
5456        let (dir, store) = seeded_store_with_dev_secrets();
5457        let manifest = json!({
5458            "schema": ENV_MANIFEST_SCHEMA_V1,
5459            "environment": {"id": "local"},
5460            "packs": [{
5461                "slot": "deployer",
5462                "kind": "greentic.deployer.local@1.0.0",
5463                "pack_ref": "builtin:deployer-local"
5464            }],
5465            "secrets": [{"path": SECRET_PATH, "from_env": "APPLY_ORDER_VAR"}],
5466            "bundles": [{"bundle_id": "quickstart", "bundle_path": fixture()}],
5467            "extensions": [{
5468                "kind": "greentic.ext.memory@1.0.0",
5469                "pack_ref": "builtin:memory"
5470            }],
5471            "messaging_endpoints": [{
5472                "name": "bot",
5473                "provider_type": "messaging.telegram.bot",
5474                "links": ["quickstart"]
5475            }]
5476        });
5477        let manifest_path = write_manifest(dir.path(), &manifest);
5478        let lookup = |_: &str| Some("tok".to_string());
5479        let plan =
5480            run_with_lookup(&store, &manifest_path, ApplyMode::DryRun, &lookup).expect("dry-run");
5481        let steps = plan.result["steps"].as_array().expect("steps");
5482        let pos = |kind: &str| {
5483            steps
5484                .iter()
5485                .position(|s| s["kind"] == kind)
5486                .unwrap_or_else(|| panic!("no `{kind}` step in {steps:?}"))
5487        };
5488        assert!(pos("ensure-environment") < pos("add-pack-binding"));
5489        assert!(pos("add-pack-binding") < pos("put-secret"));
5490        assert!(pos("put-secret") < pos("deploy-bundle"));
5491        assert!(pos("deploy-bundle") < pos("add-extension"));
5492        assert!(pos("add-extension") < pos("add-endpoint"));
5493    }
5494
5495    // --- G5 fresh-env pack diff against default bindings ---
5496
5497    /// On a fresh local env (no env on disk), `env init` will create 5 default
5498    /// pack bindings. The planner must diff manifest packs against those
5499    /// defaults so a pack targeting a default slot emits UpdatePackBinding (if
5500    /// the desired binding differs) or a no-op (if identical), never an
5501    /// unconditional AddPackBinding that would fail with SlotAlreadyBound.
5502    /// A pack targeting a non-default slot (e.g. revocation) still correctly
5503    /// emits AddPackBinding.
5504    #[test]
5505    fn fresh_env_pack_on_default_slot_emits_update_not_add() {
5506        let dir = tempdir().unwrap();
5507        let store = LocalFsStore::new(dir.path());
5508        // No env on disk — this is a fresh-env scenario.
5509
5510        // Pack 1: deployer with SAME kind+pack_ref as the default → no-op.
5511        // Pack 2: telemetry with DIFFERENT kind → update.
5512        // Pack 3: revocation (not in defaults) → add.
5513        let manifest = json!({
5514            "schema": ENV_MANIFEST_SCHEMA_V1,
5515            "environment": {"id": "local"},
5516            "packs": [
5517                {
5518                    "slot": "deployer",
5519                    "kind": crate::defaults::LOCAL_DEPLOYER_PACK,
5520                    "pack_ref": crate::defaults::LOCAL_DEPLOYER_PACK
5521                },
5522                {
5523                    "slot": "telemetry",
5524                    "kind": "greentic.telemetry.otlp@2.0.0",
5525                    "pack_ref": "builtin:otlp"
5526                },
5527                {
5528                    "slot": "revocation",
5529                    "kind": "greentic.revocation.crl@1.0.0",
5530                    "pack_ref": "builtin:crl"
5531                }
5532            ]
5533        });
5534        let manifest_path = write_manifest(dir.path(), &manifest);
5535        let plan = run_dry(&store, &manifest_path).expect("dry-run on fresh env");
5536        let steps = plan.result["steps"].as_array().expect("steps array");
5537
5538        // Find each pack step by its key (= slot name).
5539        let find_step = |key: &str| {
5540            steps
5541                .iter()
5542                .find(|s| s["key"] == key)
5543                .unwrap_or_else(|| panic!("no step with key `{key}` in {steps:?}"))
5544        };
5545
5546        // deployer: identical to default → no-op (kind is still add-pack-binding
5547        // because the no-op variant reuses that kind label).
5548        let deployer = find_step("deployer");
5549        assert_eq!(
5550            deployer["action"].as_str().unwrap(),
5551            "no-op",
5552            "deployer must be no-op (matches default): {deployer}"
5553        );
5554
5555        // telemetry: differs from default → update-pack-binding.
5556        let telemetry = find_step("telemetry");
5557        assert_eq!(
5558            telemetry["kind"].as_str().unwrap(),
5559            "update-pack-binding",
5560            "telemetry must be update (differs from default): {telemetry}"
5561        );
5562        assert_eq!(
5563            telemetry["action"].as_str().unwrap(),
5564            "update",
5565            "telemetry action must be update: {telemetry}"
5566        );
5567
5568        // revocation: not in defaults → add-pack-binding.
5569        let revocation = find_step("revocation");
5570        assert_eq!(
5571            revocation["kind"].as_str().unwrap(),
5572            "add-pack-binding",
5573            "revocation must be add (not in defaults): {revocation}"
5574        );
5575        assert_eq!(
5576            revocation["action"].as_str().unwrap(),
5577            "create",
5578            "revocation action must be create: {revocation}"
5579        );
5580    }
5581}